we (web engine): Experimental web browser project to understand the limits of Claude
at texture-validation 398 lines 13 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_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('&', "&amp;") 279 .replace('<', "&lt;") 280 .replace('>', "&gt;"); 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}