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