we (web engine): Experimental web browser project to understand the limits of Claude
at main 364 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_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('&', "&amp;") 260 .replace('<', "&lt;") 261 .replace('>', "&gt;"); 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}