we (web engine): Experimental web browser project to understand the limits of Claude
1use std::cell::RefCell;
2use std::collections::HashMap;
3
4use we_browser::css_loader::collect_stylesheets;
5use we_browser::img_loader::{collect_images, ImageStore};
6use we_browser::loader::{Resource, ResourceLoader, ABOUT_BLANK_HTML};
7use we_browser::script_loader::execute_page_scripts;
8use we_css::parser::Stylesheet;
9use we_dom::{Document, NodeId};
10use we_html::parse_html;
11use we_image::pixel::Image;
12use we_layout::layout;
13use we_platform::appkit;
14use we_platform::cg::BitmapContext;
15use we_render::{Renderer, ScrollState};
16use we_style::computed::resolve_styles;
17use we_text::font::{self, Font};
18use we_url::Url;
19
20// ---------------------------------------------------------------------------
21// Page state: holds everything needed to re-render without re-fetching
22// ---------------------------------------------------------------------------
23
24/// Pre-fetched page data. Stored so that window resizes only re-style,
25/// re-layout, and re-render — no network requests.
26struct PageState {
27 doc: Document,
28 stylesheet: Stylesheet,
29 images: ImageStore,
30}
31
32/// Browser state kept in thread-local storage so the resize handler can
33/// access it. All AppKit callbacks run on the main thread.
34struct BrowserState {
35 page: PageState,
36 font: Font,
37 bitmap: Box<BitmapContext>,
38 view: appkit::BitmapView,
39 /// Page-level scroll offset (vertical).
40 page_scroll_y: f32,
41 /// Total content height from the last layout (for scroll clamping).
42 content_height: f32,
43 /// Per-element scroll offsets for overflow:scroll/auto containers.
44 scroll_offsets: HashMap<NodeId, (f32, f32)>,
45}
46
47thread_local! {
48 static STATE: RefCell<Option<BrowserState>> = const { RefCell::new(None) };
49}
50
51// ---------------------------------------------------------------------------
52// Rendering pipeline
53// ---------------------------------------------------------------------------
54
55/// Build a `HashMap<NodeId, (f32, f32)>` of display dimensions for layout.
56fn image_sizes(store: &ImageStore) -> HashMap<NodeId, (f32, f32)> {
57 let mut sizes = HashMap::new();
58 for (node_id, res) in store {
59 if res.display_width > 0.0 || res.display_height > 0.0 {
60 sizes.insert(*node_id, (res.display_width, res.display_height));
61 }
62 }
63 sizes
64}
65
66/// Build a `HashMap<NodeId, &Image>` reference map for the renderer.
67fn image_refs(store: &ImageStore) -> HashMap<NodeId, &Image> {
68 let mut refs = HashMap::new();
69 for (node_id, res) in store {
70 if let Some(ref img) = res.image {
71 refs.insert(*node_id, img);
72 }
73 }
74 refs
75}
76
77/// Re-run the pipeline: resolve styles → layout → render → copy to bitmap.
78///
79/// Uses pre-fetched `PageState` so no network I/O happens here.
80/// Returns the total content height for scroll clamping.
81fn render_page(
82 page: &PageState,
83 font: &Font,
84 bitmap: &mut BitmapContext,
85 page_scroll_y: f32,
86 scroll_offsets: &ScrollState,
87) -> f32 {
88 let width = bitmap.width() as u32;
89 let height = bitmap.height() as u32;
90 if width == 0 || height == 0 {
91 return 0.0;
92 }
93
94 // Resolve computed styles from DOM + stylesheet.
95 let styled = match resolve_styles(
96 &page.doc,
97 std::slice::from_ref(&page.stylesheet),
98 (width as f32, height as f32),
99 ) {
100 Some(s) => s,
101 None => return 0.0,
102 };
103
104 // Build image maps for layout (sizes) and render (pixel data).
105 let sizes = image_sizes(&page.images);
106 let refs = image_refs(&page.images);
107
108 // Layout.
109 let tree = layout(
110 &styled,
111 &page.doc,
112 width as f32,
113 height as f32,
114 font,
115 &sizes,
116 );
117
118 // Render with scroll state.
119 let mut renderer = Renderer::new(width, height);
120 renderer.paint_with_scroll(&tree, font, &refs, page_scroll_y, scroll_offsets);
121
122 // Copy rendered pixels into the bitmap context's buffer.
123 let src = renderer.pixels();
124 let dst = bitmap.pixels_mut();
125 let len = src.len().min(dst.len());
126 dst[..len].copy_from_slice(&src[..len]);
127
128 // Return total content height for scroll clamping.
129 tree.root.content_height
130}
131
132/// Called by the platform crate when the window is resized.
133fn handle_resize(width: f64, height: f64) {
134 STATE.with(|state| {
135 let mut state = state.borrow_mut();
136 let state = match state.as_mut() {
137 Some(s) => s,
138 None => return,
139 };
140
141 let w = width as usize;
142 let h = height as usize;
143 if w == 0 || h == 0 {
144 return;
145 }
146
147 // Create a new bitmap context with the new dimensions.
148 let mut new_bitmap = match BitmapContext::new(w, h) {
149 Some(b) => Box::new(b),
150 None => return,
151 };
152
153 let content_height = render_page(
154 &state.page,
155 &state.font,
156 &mut new_bitmap,
157 state.page_scroll_y,
158 &state.scroll_offsets,
159 );
160 state.content_height = content_height;
161
162 // Clamp scroll position after resize (viewport may have grown).
163 let viewport_height = h as f32;
164 let max_scroll = (state.content_height - viewport_height).max(0.0);
165 state.page_scroll_y = state.page_scroll_y.clamp(0.0, max_scroll);
166
167 // Swap in the new bitmap and update the view's pointer.
168 state.bitmap = new_bitmap;
169 state.view.update_bitmap(&state.bitmap);
170 });
171}
172
173/// Called by the platform crate on scroll wheel events.
174fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) {
175 STATE.with(|state| {
176 let mut state = state.borrow_mut();
177 let state = match state.as_mut() {
178 Some(s) => s,
179 None => return,
180 };
181
182 let viewport_height = state.bitmap.height() as f32;
183 let max_scroll = (state.content_height - viewport_height).max(0.0);
184
185 // Apply scroll delta (negative dy = scroll down).
186 state.page_scroll_y = (state.page_scroll_y - dy as f32).clamp(0.0, max_scroll);
187
188 // Re-render with updated scroll position.
189 let content_height = render_page(
190 &state.page,
191 &state.font,
192 &mut state.bitmap,
193 state.page_scroll_y,
194 &state.scroll_offsets,
195 );
196 state.content_height = content_height;
197 state.view.set_needs_display();
198 });
199}
200
201// ---------------------------------------------------------------------------
202// Page loading
203// ---------------------------------------------------------------------------
204
205/// Result of loading a page: HTML text + base URL for resolving subresources.
206struct LoadedHtml {
207 text: String,
208 base_url: Url,
209}
210
211/// Load content from a command-line argument.
212///
213/// Tries the argument as a URL first (http://, https://, about:, data:),
214/// then falls back to reading it as a local file.
215/// On failure, returns an error page instead of exiting.
216fn load_from_arg(arg: &str) -> LoadedHtml {
217 // Try as URL if it has a recognized scheme.
218 if arg.starts_with("http://")
219 || arg.starts_with("https://")
220 || arg.starts_with("about:")
221 || arg.starts_with("data:")
222 {
223 let mut loader = ResourceLoader::new();
224 match loader.fetch_url(arg, None) {
225 Ok(Resource::Html { text, base_url, .. }) => {
226 return LoadedHtml { text, base_url };
227 }
228 Ok(_) => {
229 return error_page(&format!("URL did not return HTML: {arg}"));
230 }
231 Err(e) => {
232 return error_page(&format!("Failed to load {arg}: {e}"));
233 }
234 }
235 }
236
237 // Fall back to file path.
238 match std::fs::read_to_string(arg) {
239 Ok(content) => {
240 // Use a file:// base URL for resolving relative paths.
241 let abs_path =
242 std::fs::canonicalize(arg).unwrap_or_else(|_| std::path::PathBuf::from(arg));
243 let base_str = format!("file://{}", abs_path.display());
244 let base_url = Url::parse(&base_str).unwrap_or_else(|_| {
245 Url::parse("about:blank").expect("about:blank is always valid")
246 });
247 LoadedHtml {
248 text: content,
249 base_url,
250 }
251 }
252 Err(e) => error_page(&format!("Error reading {arg}: {e}")),
253 }
254}
255
256/// Generate an HTML error page for display.
257fn error_page(message: &str) -> LoadedHtml {
258 eprintln!("{message}");
259 let escaped = message
260 .replace('&', "&")
261 .replace('<', "<")
262 .replace('>', ">");
263 let html = format!(
264 "<!DOCTYPE html>\
265 <html><head><title>Error</title>\
266 <style>\
267 body {{ font-family: sans-serif; margin: 40px; color: #333; }}\
268 h1 {{ color: #c00; }}\
269 p {{ font-size: 16px; }}\
270 </style></head>\
271 <body><h1>Error</h1><p>{escaped}</p></body></html>"
272 );
273 let base_url = Url::parse("about:blank").expect("about:blank is always valid");
274 LoadedHtml {
275 text: html,
276 base_url,
277 }
278}
279
280/// Load a page: fetch HTML, parse DOM, execute scripts, collect CSS and images.
281fn load_page(loaded: LoadedHtml) -> PageState {
282 let doc = parse_html(&loaded.text);
283
284 // Execute <script> elements. Scripts may modify the DOM, so this must
285 // run before collecting CSS and images (which depend on DOM structure).
286 let mut loader = ResourceLoader::new();
287 let doc = execute_page_scripts(doc, &mut loader, &loaded.base_url);
288
289 // Fetch external stylesheets and merge with inline <style> elements.
290 let stylesheet = collect_stylesheets(&doc, &mut loader, &loaded.base_url);
291
292 // Fetch and decode images referenced by <img> elements.
293 let images = collect_images(&doc, &mut loader, &loaded.base_url);
294
295 PageState {
296 doc,
297 stylesheet,
298 images,
299 }
300}
301
302// ---------------------------------------------------------------------------
303// Entry point
304// ---------------------------------------------------------------------------
305
306fn main() {
307 // Load page from argument (URL, file path) or default to about:blank.
308 let loaded = match std::env::args().nth(1) {
309 Some(arg) => load_from_arg(&arg),
310 None => LoadedHtml {
311 text: ABOUT_BLANK_HTML.to_string(),
312 base_url: Url::parse("about:blank").expect("about:blank is always valid"),
313 },
314 };
315
316 // Parse DOM and fetch subresources (CSS, images).
317 let page = load_page(loaded);
318
319 // Load a system font for text rendering.
320 let font = match font::load_system_font() {
321 Ok(f) => f,
322 Err(e) => {
323 eprintln!("Error loading system font: {:?}", e);
324 std::process::exit(1);
325 }
326 };
327
328 let _pool = appkit::AutoreleasePool::new();
329
330 let app = appkit::App::shared();
331 app.set_activation_policy(appkit::NS_APPLICATION_ACTIVATION_POLICY_REGULAR);
332 appkit::install_app_delegate(&app);
333
334 let window = appkit::create_standard_window("we");
335 appkit::install_window_delegate(&window);
336 window.set_accepts_mouse_moved_events(true);
337
338 // Initial render at the default window size (800x600).
339 let mut bitmap =
340 Box::new(BitmapContext::new(800, 600).expect("failed to create bitmap context"));
341 let scroll_offsets: HashMap<NodeId, (f32, f32)> = HashMap::new();
342 let content_height = render_page(&page, &font, &mut bitmap, 0.0, &scroll_offsets);
343
344 // Create the view backed by the rendered bitmap.
345 let frame = appkit::NSRect::new(0.0, 0.0, 800.0, 600.0);
346 let view = appkit::BitmapView::new(frame, &bitmap);
347 window.set_content_view(&view.id());
348
349 // Store state for the resize handler.
350 STATE.with(|state| {
351 *state.borrow_mut() = Some(BrowserState {
352 page,
353 font,
354 bitmap,
355 view,
356 page_scroll_y: 0.0,
357 content_height,
358 scroll_offsets,
359 });
360 });
361
362 // Register resize and scroll handlers.
363 appkit::set_resize_handler(handle_resize);
364 appkit::set_scroll_handler(handle_scroll);
365
366 window.make_key_and_order_front();
367 app.activate();
368 app.run();
369}