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