we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement overflow scrolling with scroll bars

Add scrollable overflow regions for overflow:scroll and overflow:auto
boxes, with rendered scroll bars and mouse wheel event support.

Layout crate:
- Add content_height field to LayoutBox tracking natural content height
before CSS height override (used for overflow detection)
- Reserve SCROLLBAR_WIDTH (15px) from content area for overflow:scroll
- Export SCROLLBAR_WIDTH constant

Render crate:
- Add ScrollState type (HashMap<NodeId, (f32, f32)>) for scroll offsets
- Modify display list builder to accept scroll state and translate offset
- Apply scroll offset to child coordinates in scrollable containers
- Render vertical scroll bars (track + proportional thumb) for
overflow:scroll (always) and overflow:auto (when content overflows)
- Add page-level scroll support via paint_with_scroll()
- Add build_display_list_with_page_scroll() for viewport scrolling
- 6 new tests: scrollbar rendering, scroll offset, thumb proportionality,
overflow:auto conditional scrollbar, page scroll

Platform crate:
- Add scrollWheel: event handler to WeView class
- Add set_scroll_handler() for registering scroll callbacks
- Forward scroll delta (dx, dy) and mouse position to handler

Browser crate:
- Add page_scroll_y, content_height, scroll_offsets to BrowserState
- Handle scroll wheel events: update page scroll, clamp to bounds,
re-render
- Pass scroll state through rendering pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+524 -34
+70 -9
crates/browser/src/main.rs
··· 11 11 use we_layout::layout; 12 12 use we_platform::appkit; 13 13 use we_platform::cg::BitmapContext; 14 - use we_render::Renderer; 14 + use we_render::{Renderer, ScrollState}; 15 15 use we_style::computed::resolve_styles; 16 16 use we_text::font::{self, Font}; 17 17 use we_url::Url; ··· 35 35 font: Font, 36 36 bitmap: Box<BitmapContext>, 37 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)>, 38 44 } 39 45 40 46 thread_local! { ··· 70 76 /// Re-run the pipeline: resolve styles → layout → render → copy to bitmap. 71 77 /// 72 78 /// Uses pre-fetched `PageState` so no network I/O happens here. 73 - fn render_page(page: &PageState, font: &Font, bitmap: &mut BitmapContext) { 79 + /// Returns the total content height for scroll clamping. 80 + fn render_page( 81 + page: &PageState, 82 + font: &Font, 83 + bitmap: &mut BitmapContext, 84 + page_scroll_y: f32, 85 + scroll_offsets: &ScrollState, 86 + ) -> f32 { 74 87 let width = bitmap.width() as u32; 75 88 let height = bitmap.height() as u32; 76 89 if width == 0 || height == 0 { 77 - return; 90 + return 0.0; 78 91 } 79 92 80 93 // Resolve computed styles from DOM + stylesheet. ··· 84 97 (width as f32, height as f32), 85 98 ) { 86 99 Some(s) => s, 87 - None => return, 100 + None => return 0.0, 88 101 }; 89 102 90 103 // Build image maps for layout (sizes) and render (pixel data). ··· 101 114 &sizes, 102 115 ); 103 116 104 - // Render. 117 + // Render with scroll state. 105 118 let mut renderer = Renderer::new(width, height); 106 - renderer.paint(&tree, font, &refs); 119 + renderer.paint_with_scroll(&tree, font, &refs, page_scroll_y, scroll_offsets); 107 120 108 121 // Copy rendered pixels into the bitmap context's buffer. 109 122 let src = renderer.pixels(); 110 123 let dst = bitmap.pixels_mut(); 111 124 let len = src.len().min(dst.len()); 112 125 dst[..len].copy_from_slice(&src[..len]); 126 + 127 + // Return total content height for scroll clamping. 128 + tree.root.content_height 113 129 } 114 130 115 131 /// Called by the platform crate when the window is resized. ··· 133 149 None => return, 134 150 }; 135 151 136 - render_page(&state.page, &state.font, &mut new_bitmap); 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); 137 165 138 166 // Swap in the new bitmap and update the view's pointer. 139 167 state.bitmap = new_bitmap; 140 168 state.view.update_bitmap(&state.bitmap); 169 + }); 170 + } 171 + 172 + /// Called by the platform crate on scroll wheel events. 173 + fn 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(); 141 197 }); 142 198 } 143 199 ··· 277 333 // Initial render at the default window size (800x600). 278 334 let mut bitmap = 279 335 Box::new(BitmapContext::new(800, 600).expect("failed to create bitmap context")); 280 - render_page(&page, &font, &mut bitmap); 336 + let scroll_offsets: HashMap<NodeId, (f32, f32)> = HashMap::new(); 337 + let content_height = render_page(&page, &font, &mut bitmap, 0.0, &scroll_offsets); 281 338 282 339 // Create the view backed by the rendered bitmap. 283 340 let frame = appkit::NSRect::new(0.0, 0.0, 800.0, 600.0); ··· 291 348 font, 292 349 bitmap, 293 350 view, 351 + page_scroll_y: 0.0, 352 + content_height, 353 + scroll_offsets, 294 354 }); 295 355 }); 296 356 297 - // Register resize handler so re-layout happens on window resize. 357 + // Register resize and scroll handlers. 298 358 appkit::set_resize_handler(handle_resize); 359 + appkit::set_scroll_handler(handle_scroll); 299 360 300 361 window.make_key_and_order_front(); 301 362 app.activate();
+16 -1
crates/layout/src/lib.rs
··· 13 13 }; 14 14 use we_text::font::Font; 15 15 16 + /// Width of scroll bars in pixels. 17 + pub const SCROLLBAR_WIDTH: f32 = 15.0; 18 + 16 19 /// Edge sizes for box model (margin, padding, border). 17 20 #[derive(Debug, Clone, Copy, Default, PartialEq)] 18 21 pub struct EdgeSizes { ··· 109 112 pub css_offsets: [LengthOrAuto; 4], 110 113 /// CSS `visibility` property. 111 114 pub visibility: Visibility, 115 + /// Natural content height before CSS height override. 116 + /// Used to determine overflow for scroll containers. 117 + pub content_height: f32, 112 118 } 113 119 114 120 impl LayoutBox { ··· 160 166 ], 161 167 css_offsets: [style.top, style.right, style.bottom, style.left], 162 168 visibility: style.visibility, 169 + content_height: 0.0, 163 170 } 164 171 } 165 172 ··· 517 524 b.rect.y = content_y; 518 525 b.rect.width = content_width; 519 526 527 + // For overflow:scroll, reserve space for vertical scrollbar. 528 + if b.overflow == Overflow::Scroll { 529 + b.rect.width = (b.rect.width - SCROLLBAR_WIDTH).max(0.0); 530 + } 531 + 520 532 // Replaced elements (e.g., <img>) have intrinsic dimensions. 521 533 if let Some((rw, rh)) = b.replaced_size { 522 - b.rect.width = rw.min(content_width); 534 + b.rect.width = rw.min(b.rect.width); 523 535 b.rect.height = rh; 524 536 apply_relative_offset(b, available_width, viewport_height); 525 537 return; ··· 537 549 // Handled by the parent's inline layout. 538 550 } 539 551 } 552 + 553 + // Save the natural content height before CSS height override. 554 + b.content_height = b.rect.height; 540 555 541 556 // Apply explicit CSS height (adjusted for box-sizing), overriding auto height. 542 557 match b.css_height {
+41
crates/platform/src/appkit.rs
··· 419 419 c"v@:@", 420 420 ); 421 421 422 + // scrollWheel: — call scroll handler with delta and mouse location 423 + extern "C" fn scroll_wheel(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { 424 + let dx: f64 = msg_send![event, scrollingDeltaX]; 425 + let dy: f64 = msg_send![event, scrollingDeltaY]; 426 + let raw_loc: NSPoint = msg_send![event, locationInWindow]; 427 + let loc: NSPoint = 428 + msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::<c_void>()]; 429 + // SAFETY: We are on the main thread (AppKit event loop). 430 + unsafe { 431 + if let Some(handler) = SCROLL_HANDLER { 432 + handler(dx, dy, loc.x, loc.y); 433 + } 434 + } 435 + } 436 + 437 + let sel = Sel::register(c"scrollWheel:"); 438 + view_class.add_method( 439 + sel, 440 + unsafe { std::mem::transmute::<*const (), Imp>(scroll_wheel as *const ()) }, 441 + c"v@:@", 442 + ); 443 + 422 444 view_class.register(); 423 445 } 424 446 ··· 506 528 // SAFETY: Called from the main thread before `app.run()`. 507 529 unsafe { 508 530 RESIZE_HANDLER = Some(handler); 531 + } 532 + } 533 + 534 + /// Global scroll callback, called from `scrollWheel:` with the scroll 535 + /// deltas (dx, dy) and mouse location (x, y) in view coordinates. 536 + /// 537 + /// # Safety 538 + /// 539 + /// Accessed only from the main thread (the AppKit event loop). 540 + static mut SCROLL_HANDLER: Option<fn(f64, f64, f64, f64)> = None; 541 + 542 + /// Register a function to be called when a scroll wheel event occurs. 543 + /// 544 + /// The handler receives `(delta_x, delta_y, mouse_x, mouse_y)`. 545 + /// Only one handler can be active at a time. 546 + pub fn set_scroll_handler(handler: fn(f64, f64, f64, f64)) { 547 + // SAFETY: Called from the main thread before `app.run()`. 548 + unsafe { 549 + SCROLL_HANDLER = Some(handler); 509 550 } 510 551 } 511 552
+397 -24
crates/render/src/lib.rs
··· 8 8 use we_css::values::Color; 9 9 use we_dom::NodeId; 10 10 use we_image::pixel::Image; 11 - use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine}; 11 + use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; 12 12 use we_style::computed::{BorderStyle, Overflow, TextDecoration, Visibility}; 13 13 use we_text::font::Font; 14 + 15 + /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. 16 + pub type ScrollState = HashMap<NodeId, (f32, f32)>; 17 + 18 + /// Scroll bar track color (light gray). 19 + const SCROLLBAR_TRACK_COLOR: Color = Color { 20 + r: 230, 21 + g: 230, 22 + b: 230, 23 + a: 255, 24 + }; 25 + 26 + /// Scroll bar thumb color (darker gray). 27 + const SCROLLBAR_THUMB_COLOR: Color = Color { 28 + r: 160, 29 + g: 160, 30 + b: 160, 31 + a: 255, 32 + }; 14 33 15 34 /// A paint command in the display list. 16 35 #[derive(Debug)] ··· 57 76 /// Walks the tree in depth-first pre-order (painter's order): 58 77 /// backgrounds first, then borders, then text on top. 59 78 pub fn build_display_list(tree: &LayoutTree) -> DisplayList { 79 + build_display_list_with_scroll(tree, &HashMap::new()) 80 + } 81 + 82 + /// Build a display list from a layout tree with scroll state. 83 + /// 84 + /// `page_scroll` is the viewport-level scroll offset (x, y) applied to all content. 85 + /// `scroll_state` maps element NodeIds to their per-element scroll offsets. 86 + pub fn build_display_list_with_scroll( 87 + tree: &LayoutTree, 88 + scroll_state: &ScrollState, 89 + ) -> DisplayList { 60 90 let mut list = DisplayList::new(); 61 - paint_box(&tree.root, &mut list); 91 + paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state); 62 92 list 63 93 } 64 94 65 - fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { 95 + /// Build a display list with page-level scrolling. 96 + /// 97 + /// `page_scroll_y` shifts all content vertically (viewport-level scroll). 98 + pub fn build_display_list_with_page_scroll( 99 + tree: &LayoutTree, 100 + page_scroll_y: f32, 101 + scroll_state: &ScrollState, 102 + ) -> DisplayList { 103 + let mut list = DisplayList::new(); 104 + paint_box(&tree.root, &mut list, (0.0, -page_scroll_y), scroll_state); 105 + list 106 + } 107 + 108 + fn paint_box( 109 + layout_box: &LayoutBox, 110 + list: &mut DisplayList, 111 + translate: (f32, f32), 112 + scroll_state: &ScrollState, 113 + ) { 66 114 let visible = layout_box.visibility == Visibility::Visible; 115 + let tx = translate.0; 116 + let ty = translate.1; 67 117 68 118 if visible { 69 - paint_background(layout_box, list); 70 - paint_borders(layout_box, list); 119 + paint_background(layout_box, list, tx, ty); 120 + paint_borders(layout_box, list, tx, ty); 71 121 72 122 // Emit image paint command for replaced elements. 73 123 if let Some((rw, rh)) = layout_box.replaced_size { 74 124 if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 75 125 list.push(PaintCommand::DrawImage { 76 - x: layout_box.rect.x, 77 - y: layout_box.rect.y, 126 + x: layout_box.rect.x + tx, 127 + y: layout_box.rect.y + ty, 78 128 width: rw, 79 129 height: rh, 80 130 node_id, ··· 82 132 } 83 133 } 84 134 85 - paint_text(layout_box, list); 135 + paint_text(layout_box, list, tx, ty); 86 136 } 87 137 138 + // Determine if this box is scrollable. 139 + let scrollable = 140 + layout_box.overflow == Overflow::Scroll || layout_box.overflow == Overflow::Auto; 141 + 88 142 // If this box has overflow clipping, push a clip rect for the padding box. 89 143 let clips = layout_box.overflow != Overflow::Visible; 90 144 if clips { 91 145 let clip = padding_box(layout_box); 92 146 list.push(PaintCommand::PushClip { 93 - x: clip.x, 94 - y: clip.y, 147 + x: clip.x + tx, 148 + y: clip.y + ty, 95 149 width: clip.width, 96 150 height: clip.height, 97 151 }); 98 152 } 99 153 154 + // Compute child translate: adds scroll offset for scrollable boxes. 155 + let mut child_translate = translate; 156 + if scrollable { 157 + if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 158 + if let Some(&(sx, sy)) = scroll_state.get(&node_id) { 159 + child_translate.0 -= sx; 160 + child_translate.1 -= sy; 161 + } 162 + } 163 + } 164 + 100 165 // Always recurse into children — they may override visibility. 101 166 for child in &layout_box.children { 102 - paint_box(child, list); 167 + paint_box(child, list, child_translate, scroll_state); 103 168 } 104 169 105 170 if clips { 106 171 list.push(PaintCommand::PopClip); 107 172 } 173 + 174 + // Paint scroll bars after PopClip so they're not clipped by the container. 175 + if scrollable && visible { 176 + paint_scrollbars(layout_box, list, tx, ty, scroll_state); 177 + } 108 178 } 109 179 110 180 /// Compute the padding box rectangle for a layout box. ··· 127 197 } 128 198 } 129 199 130 - fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { 200 + /// Public variant of `node_id_from_box_type` for use by other crates. 201 + pub fn node_id_from_box_type_pub(box_type: &BoxType) -> Option<NodeId> { 202 + node_id_from_box_type(box_type) 203 + } 204 + 205 + fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 131 206 let bg = layout_box.background_color; 132 207 // Only paint if the background is not fully transparent and the box has area. 133 208 if bg.a == 0 { ··· 136 211 if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { 137 212 // Background covers the padding box (content + padding), not including border. 138 213 list.push(PaintCommand::FillRect { 139 - x: layout_box.rect.x, 140 - y: layout_box.rect.y, 214 + x: layout_box.rect.x + tx, 215 + y: layout_box.rect.y + ty, 141 216 width: layout_box.rect.width, 142 217 height: layout_box.rect.height, 143 218 color: bg, ··· 145 220 } 146 221 } 147 222 148 - fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { 223 + fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 149 224 let b = &layout_box.border; 150 225 let r = &layout_box.rect; 151 226 let styles = &layout_box.border_styles; 152 227 let colors = &layout_box.border_colors; 153 228 154 229 // Border box starts at content origin minus padding and border. 155 - let bx = r.x - layout_box.padding.left - b.left; 156 - let by = r.y - layout_box.padding.top - b.top; 230 + let bx = r.x - layout_box.padding.left - b.left + tx; 231 + let by = r.y - layout_box.padding.top - b.top + ty; 157 232 let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; 158 233 let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; 159 234 ··· 199 274 } 200 275 } 201 276 202 - fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { 277 + fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 203 278 for line in &layout_box.lines { 204 279 let color = line.color; 205 280 let font_size = line.font_size; ··· 208 283 // before the text in painter's order. 209 284 let glyph_idx = list.len(); 210 285 286 + // Create a translated copy of the text line. 287 + let mut translated_line = line.clone(); 288 + translated_line.x += tx; 289 + translated_line.y += ty; 290 + 211 291 list.push(PaintCommand::DrawGlyphs { 212 - line: line.clone(), 292 + line: translated_line, 213 293 font_size, 214 294 color, 215 295 }); 216 296 217 297 // Draw underline as a 1px line below the baseline. 218 298 if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { 219 - let baseline_y = line.y + font_size; 299 + let baseline_y = line.y + ty + font_size; 220 300 let underline_y = baseline_y + 2.0; 221 301 list.push(PaintCommand::FillRect { 222 - x: line.x, 302 + x: line.x + tx, 223 303 y: underline_y, 224 304 width: line.width, 225 305 height: 1.0, ··· 232 312 list.insert( 233 313 glyph_idx, 234 314 PaintCommand::FillRect { 235 - x: line.x, 236 - y: line.y, 315 + x: line.x + tx, 316 + y: line.y + ty, 237 317 width: line.width, 238 318 height: font_size * 1.2, 239 319 color: line.background_color, ··· 243 323 } 244 324 } 245 325 326 + /// Paint scroll bars for a scrollable box. 327 + fn paint_scrollbars( 328 + layout_box: &LayoutBox, 329 + list: &mut DisplayList, 330 + tx: f32, 331 + ty: f32, 332 + scroll_state: &ScrollState, 333 + ) { 334 + let pad = padding_box(layout_box); 335 + let viewport_height = pad.height; 336 + let content_height = layout_box.content_height; 337 + 338 + // Determine whether scroll bars should be shown. 339 + let show_vertical = match layout_box.overflow { 340 + Overflow::Scroll => true, 341 + Overflow::Auto => content_height > viewport_height, 342 + _ => false, 343 + }; 344 + 345 + if !show_vertical || viewport_height <= 0.0 { 346 + return; 347 + } 348 + 349 + // Scroll bar track: right edge of the padding box. 350 + let track_x = pad.x + pad.width - SCROLLBAR_WIDTH + tx; 351 + let track_y = pad.y + ty; 352 + let track_height = viewport_height; 353 + 354 + // Paint the track background. 355 + list.push(PaintCommand::FillRect { 356 + x: track_x, 357 + y: track_y, 358 + width: SCROLLBAR_WIDTH, 359 + height: track_height, 360 + color: SCROLLBAR_TRACK_COLOR, 361 + }); 362 + 363 + // Compute thumb size and position. 364 + let max_content = content_height.max(viewport_height); 365 + let thumb_ratio = viewport_height / max_content; 366 + let thumb_height = (thumb_ratio * track_height).max(20.0).min(track_height); 367 + 368 + // Get current scroll offset. 369 + let scroll_y = node_id_from_box_type(&layout_box.box_type) 370 + .and_then(|id| scroll_state.get(&id)) 371 + .map(|&(_, sy)| sy) 372 + .unwrap_or(0.0); 373 + 374 + let max_scroll = (content_height - viewport_height).max(0.0); 375 + let scroll_ratio = if max_scroll > 0.0 { 376 + scroll_y / max_scroll 377 + } else { 378 + 0.0 379 + }; 380 + let thumb_y = track_y + scroll_ratio * (track_height - thumb_height); 381 + 382 + // Paint the thumb. 383 + list.push(PaintCommand::FillRect { 384 + x: track_x, 385 + y: thumb_y, 386 + width: SCROLLBAR_WIDTH, 387 + height: thumb_height, 388 + color: SCROLLBAR_THUMB_COLOR, 389 + }); 390 + } 391 + 246 392 /// An axis-aligned clip rectangle. 247 393 #[derive(Debug, Clone, Copy)] 248 394 struct ClipRect { ··· 336 482 font: &Font, 337 483 images: &HashMap<NodeId, &Image>, 338 484 ) { 339 - let display_list = build_display_list(layout_tree); 485 + self.paint_with_scroll(layout_tree, font, images, 0.0, &HashMap::new()); 486 + } 487 + 488 + /// Paint a layout tree with scroll state into the pixel buffer. 489 + pub fn paint_with_scroll( 490 + &mut self, 491 + layout_tree: &LayoutTree, 492 + font: &Font, 493 + images: &HashMap<NodeId, &Image>, 494 + page_scroll_y: f32, 495 + scroll_state: &ScrollState, 496 + ) { 497 + let display_list = 498 + build_display_list_with_page_scroll(layout_tree, page_scroll_y, scroll_state); 340 499 for cmd in &display_list { 341 500 match cmd { 342 501 PaintCommand::FillRect { ··· 1292 1451 green_fills.count(), 1293 1452 0, 1294 1453 "display:none element should not be in display list" 1454 + ); 1455 + } 1456 + 1457 + // --- Overflow scrolling tests --- 1458 + 1459 + #[test] 1460 + fn overflow_scroll_renders_scrollbar_always() { 1461 + // overflow:scroll should always render scroll bars, even when content fits. 1462 + let html_str = r#"<!DOCTYPE html> 1463 + <html><head><style> 1464 + body { margin: 0; } 1465 + .container { overflow: scroll; width: 200px; height: 200px; } 1466 + .child { height: 50px; } 1467 + </style></head> 1468 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1469 + let doc = we_html::parse_html(html_str); 1470 + let tree = layout_doc(&doc); 1471 + let list = build_display_list(&tree); 1472 + 1473 + // Should have scroll bar track and thumb FillRect commands. 1474 + let scrollbar_fills: Vec<_> = list 1475 + .iter() 1476 + .filter(|c| { 1477 + matches!(c, PaintCommand::FillRect { color, .. } 1478 + if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) 1479 + }) 1480 + .collect(); 1481 + 1482 + assert!( 1483 + scrollbar_fills.len() >= 2, 1484 + "overflow:scroll should render track and thumb (got {} fills)", 1485 + scrollbar_fills.len() 1486 + ); 1487 + } 1488 + 1489 + #[test] 1490 + fn overflow_auto_scrollbar_only_when_overflows() { 1491 + // overflow:auto with content that fits should NOT show scroll bars. 1492 + let html_str = r#"<!DOCTYPE html> 1493 + <html><head><style> 1494 + body { margin: 0; } 1495 + .container { overflow: auto; width: 200px; height: 200px; } 1496 + .child { height: 50px; } 1497 + </style></head> 1498 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1499 + let doc = we_html::parse_html(html_str); 1500 + let tree = layout_doc(&doc); 1501 + let list = build_display_list(&tree); 1502 + 1503 + let scrollbar_fills = list.iter().filter(|c| { 1504 + matches!(c, PaintCommand::FillRect { color, .. } 1505 + if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) 1506 + }); 1507 + 1508 + assert_eq!( 1509 + scrollbar_fills.count(), 1510 + 0, 1511 + "overflow:auto should not render scroll bars when content fits" 1512 + ); 1513 + } 1514 + 1515 + #[test] 1516 + fn overflow_auto_scrollbar_when_content_overflows() { 1517 + // overflow:auto with content taller than container should show scroll bars. 1518 + let html_str = r#"<!DOCTYPE html> 1519 + <html><head><style> 1520 + body { margin: 0; } 1521 + .container { overflow: auto; width: 200px; height: 100px; } 1522 + .child { background-color: red; height: 500px; } 1523 + </style></head> 1524 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1525 + let doc = we_html::parse_html(html_str); 1526 + let tree = layout_doc(&doc); 1527 + let list = build_display_list(&tree); 1528 + 1529 + let scrollbar_fills: Vec<_> = list 1530 + .iter() 1531 + .filter(|c| { 1532 + matches!(c, PaintCommand::FillRect { color, .. } 1533 + if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) 1534 + }) 1535 + .collect(); 1536 + 1537 + assert!( 1538 + scrollbar_fills.len() >= 2, 1539 + "overflow:auto should render scroll bars when content overflows (got {} fills)", 1540 + scrollbar_fills.len() 1541 + ); 1542 + } 1543 + 1544 + #[test] 1545 + fn scroll_offset_shifts_content() { 1546 + // Scrolling should shift content within a scroll container. 1547 + let html_str = r#"<!DOCTYPE html> 1548 + <html><head><style> 1549 + body { margin: 0; } 1550 + .container { overflow: scroll; width: 200px; height: 100px; } 1551 + .child { background-color: red; width: 200px; height: 500px; } 1552 + </style></head> 1553 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1554 + let doc = we_html::parse_html(html_str); 1555 + let font = test_font(); 1556 + let tree = layout_doc(&doc); 1557 + 1558 + // Find the container's NodeId to set scroll offset. 1559 + let container_id = tree 1560 + .root 1561 + .iter() 1562 + .find_map(|b| { 1563 + if b.overflow == Overflow::Scroll { 1564 + node_id_from_box_type(&b.box_type) 1565 + } else { 1566 + None 1567 + } 1568 + }) 1569 + .expect("should find scroll container"); 1570 + 1571 + // Without scroll: pixel at (50, 50) should be red (inside child). 1572 + let mut renderer = Renderer::new(300, 300); 1573 + renderer.paint(&tree, &font, &HashMap::new()); 1574 + let pixels = renderer.pixels(); 1575 + let offset_50_50 = ((50 * 300 + 50) * 4) as usize; 1576 + assert_eq!( 1577 + pixels[offset_50_50 + 2], 1578 + 255, 1579 + "before scroll: (50,50) should be red" 1580 + ); 1581 + 1582 + // With scroll offset 60px: content shifts up, so pixel at (50, 50) should 1583 + // still be red (content extends to 500px), but pixel at (50, 0) should now 1584 + // show content that was at y=60. 1585 + let mut scroll_state = HashMap::new(); 1586 + scroll_state.insert(container_id, (0.0, 60.0)); 1587 + let mut renderer2 = Renderer::new(300, 300); 1588 + renderer2.paint_with_scroll(&tree, &font, &HashMap::new(), 0.0, &scroll_state); 1589 + let pixels2 = renderer2.pixels(); 1590 + 1591 + // After scrolling 60px, content starts at y=-60 in the viewport. 1592 + // The container clips to its bounds, so pixel at (50, 50) is still visible 1593 + // red content (it was at y=110 in original content, now at y=50). 1594 + assert_eq!( 1595 + pixels2[offset_50_50 + 2], 1596 + 255, 1597 + "after scroll: (50,50) should still be red (content is tall)" 1598 + ); 1599 + } 1600 + 1601 + #[test] 1602 + fn scroll_bar_thumb_proportional() { 1603 + // Thumb size should be proportional to viewport/content ratio. 1604 + let html_str = r#"<!DOCTYPE html> 1605 + <html><head><style> 1606 + body { margin: 0; } 1607 + .container { overflow: scroll; width: 200px; height: 100px; } 1608 + .child { height: 400px; } 1609 + </style></head> 1610 + <body><div class="container"><div class="child"></div></div></body></html>"#; 1611 + let doc = we_html::parse_html(html_str); 1612 + let tree = layout_doc(&doc); 1613 + let list = build_display_list(&tree); 1614 + 1615 + // Find the thumb FillRect (SCROLLBAR_THUMB_COLOR). 1616 + let thumb = list.iter().find(|c| { 1617 + matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_THUMB_COLOR) 1618 + }); 1619 + 1620 + assert!(thumb.is_some(), "should have a scroll bar thumb"); 1621 + if let Some(PaintCommand::FillRect { height, .. }) = thumb { 1622 + // Container height is 100, content is 400. 1623 + // Thumb ratio = 100/400 = 0.25, track height = 100. 1624 + // Thumb height = max(0.25 * 100, 20) = 25. 1625 + assert!( 1626 + *height >= 20.0 && *height <= 100.0, 1627 + "thumb height {} should be proportional and within bounds", 1628 + height 1629 + ); 1630 + } 1631 + } 1632 + 1633 + #[test] 1634 + fn page_scroll_shifts_all_content() { 1635 + let html_str = r#"<!DOCTYPE html> 1636 + <html><head><style> 1637 + body { margin: 0; } 1638 + .block { background-color: red; width: 100px; height: 100px; } 1639 + </style></head> 1640 + <body><div class="block"></div></body></html>"#; 1641 + let doc = we_html::parse_html(html_str); 1642 + let font = test_font(); 1643 + let tree = layout_doc(&doc); 1644 + 1645 + // Without page scroll: red at (50, 50). 1646 + let mut r1 = Renderer::new(200, 200); 1647 + r1.paint(&tree, &font, &HashMap::new()); 1648 + let offset = ((50 * 200 + 50) * 4) as usize; 1649 + assert_eq!(r1.pixels()[offset + 2], 255, "should be red without scroll"); 1650 + 1651 + // With page scroll 80px: the red block shifts up by 80px. 1652 + // Pixel at (50, 50) was at y=130 in content, which is beyond the 100px block. 1653 + let mut r2 = Renderer::new(200, 200); 1654 + r2.paint_with_scroll(&tree, &font, &HashMap::new(), 80.0, &HashMap::new()); 1655 + let pixels2 = r2.pixels(); 1656 + // At (50, 50) with 80px scroll: this shows content at y=130, which is white. 1657 + assert_eq!( 1658 + pixels2[offset], 255, 1659 + "should be white at (50,50) with 80px page scroll" 1660 + ); 1661 + 1662 + // Pixel at (50, 10) with 80px scroll shows content at y=90, which is still red. 1663 + let offset_10 = ((10 * 200 + 50) * 4) as usize; 1664 + assert_eq!( 1665 + pixels2[offset_10 + 2], 1666 + 255, 1667 + "should be red at (50,10) with 80px page scroll" 1295 1668 ); 1296 1669 } 1297 1670 }