we (web engine): Experimental web browser project to understand the limits of Claude
at encoding-sniffing 369 lines 12 kB view raw
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('&', "&amp;") 261 .replace('<', "&lt;") 262 .replace('>', "&gt;"); 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}