diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index a3ae9bd..114c22a 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -11,7 +11,7 @@ use we_image::pixel::Image; use we_layout::layout; use we_platform::appkit; use we_platform::cg::BitmapContext; -use we_render::Renderer; +use we_render::{Renderer, ScrollState}; use we_style::computed::resolve_styles; use we_text::font::{self, Font}; use we_url::Url; @@ -35,6 +35,12 @@ struct BrowserState { font: Font, bitmap: Box, view: appkit::BitmapView, + /// Page-level scroll offset (vertical). + page_scroll_y: f32, + /// Total content height from the last layout (for scroll clamping). + content_height: f32, + /// Per-element scroll offsets for overflow:scroll/auto containers. + scroll_offsets: HashMap, } thread_local! { @@ -70,11 +76,18 @@ fn image_refs(store: &ImageStore) -> HashMap { /// Re-run the pipeline: resolve styles → layout → render → copy to bitmap. /// /// Uses pre-fetched `PageState` so no network I/O happens here. -fn render_page(page: &PageState, font: &Font, bitmap: &mut BitmapContext) { +/// Returns the total content height for scroll clamping. +fn render_page( + page: &PageState, + font: &Font, + bitmap: &mut BitmapContext, + page_scroll_y: f32, + scroll_offsets: &ScrollState, +) -> f32 { let width = bitmap.width() as u32; let height = bitmap.height() as u32; if width == 0 || height == 0 { - return; + return 0.0; } // Resolve computed styles from DOM + stylesheet. @@ -84,7 +97,7 @@ fn render_page(page: &PageState, font: &Font, bitmap: &mut BitmapContext) { (width as f32, height as f32), ) { Some(s) => s, - None => return, + None => return 0.0, }; // Build image maps for layout (sizes) and render (pixel data). @@ -101,15 +114,18 @@ fn render_page(page: &PageState, font: &Font, bitmap: &mut BitmapContext) { &sizes, ); - // Render. + // Render with scroll state. let mut renderer = Renderer::new(width, height); - renderer.paint(&tree, font, &refs); + renderer.paint_with_scroll(&tree, font, &refs, page_scroll_y, scroll_offsets); // Copy rendered pixels into the bitmap context's buffer. let src = renderer.pixels(); let dst = bitmap.pixels_mut(); let len = src.len().min(dst.len()); dst[..len].copy_from_slice(&src[..len]); + + // Return total content height for scroll clamping. + tree.root.content_height } /// Called by the platform crate when the window is resized. @@ -133,7 +149,19 @@ fn handle_resize(width: f64, height: f64) { None => return, }; - render_page(&state.page, &state.font, &mut new_bitmap); + let content_height = render_page( + &state.page, + &state.font, + &mut new_bitmap, + state.page_scroll_y, + &state.scroll_offsets, + ); + state.content_height = content_height; + + // Clamp scroll position after resize (viewport may have grown). + let viewport_height = h as f32; + let max_scroll = (state.content_height - viewport_height).max(0.0); + state.page_scroll_y = state.page_scroll_y.clamp(0.0, max_scroll); // Swap in the new bitmap and update the view's pointer. state.bitmap = new_bitmap; @@ -141,6 +169,34 @@ fn handle_resize(width: f64, height: f64) { }); } +/// Called by the platform crate on scroll wheel events. +fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let state = match state.as_mut() { + Some(s) => s, + None => return, + }; + + let viewport_height = state.bitmap.height() as f32; + let max_scroll = (state.content_height - viewport_height).max(0.0); + + // Apply scroll delta (negative dy = scroll down). + state.page_scroll_y = (state.page_scroll_y - dy as f32).clamp(0.0, max_scroll); + + // Re-render with updated scroll position. + let content_height = render_page( + &state.page, + &state.font, + &mut state.bitmap, + state.page_scroll_y, + &state.scroll_offsets, + ); + state.content_height = content_height; + state.view.set_needs_display(); + }); +} + // --------------------------------------------------------------------------- // Page loading // --------------------------------------------------------------------------- @@ -277,7 +333,8 @@ fn main() { // Initial render at the default window size (800x600). let mut bitmap = Box::new(BitmapContext::new(800, 600).expect("failed to create bitmap context")); - render_page(&page, &font, &mut bitmap); + let scroll_offsets: HashMap = HashMap::new(); + let content_height = render_page(&page, &font, &mut bitmap, 0.0, &scroll_offsets); // Create the view backed by the rendered bitmap. let frame = appkit::NSRect::new(0.0, 0.0, 800.0, 600.0); @@ -291,11 +348,15 @@ fn main() { font, bitmap, view, + page_scroll_y: 0.0, + content_height, + scroll_offsets, }); }); - // Register resize handler so re-layout happens on window resize. + // Register resize and scroll handlers. appkit::set_resize_handler(handle_resize); + appkit::set_scroll_handler(handle_scroll); window.make_key_and_order_front(); app.activate(); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index adedccd..6ee8c01 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -13,6 +13,9 @@ use we_style::computed::{ }; use we_text::font::Font; +/// Width of scroll bars in pixels. +pub const SCROLLBAR_WIDTH: f32 = 15.0; + /// Edge sizes for box model (margin, padding, border). #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct EdgeSizes { @@ -109,6 +112,9 @@ pub struct LayoutBox { pub css_offsets: [LengthOrAuto; 4], /// CSS `visibility` property. pub visibility: Visibility, + /// Natural content height before CSS height override. + /// Used to determine overflow for scroll containers. + pub content_height: f32, } impl LayoutBox { @@ -160,6 +166,7 @@ impl LayoutBox { ], css_offsets: [style.top, style.right, style.bottom, style.left], visibility: style.visibility, + content_height: 0.0, } } @@ -517,9 +524,14 @@ fn compute_layout( b.rect.y = content_y; b.rect.width = content_width; + // For overflow:scroll, reserve space for vertical scrollbar. + if b.overflow == Overflow::Scroll { + b.rect.width = (b.rect.width - SCROLLBAR_WIDTH).max(0.0); + } + // Replaced elements (e.g., ) have intrinsic dimensions. if let Some((rw, rh)) = b.replaced_size { - b.rect.width = rw.min(content_width); + b.rect.width = rw.min(b.rect.width); b.rect.height = rh; apply_relative_offset(b, available_width, viewport_height); return; @@ -538,6 +550,9 @@ fn compute_layout( } } + // Save the natural content height before CSS height override. + b.content_height = b.rect.height; + // Apply explicit CSS height (adjusted for box-sizing), overriding auto height. match b.css_height { LengthOrAuto::Length(h) => { diff --git a/crates/platform/src/appkit.rs b/crates/platform/src/appkit.rs index 17a9975..83951d6 100644 --- a/crates/platform/src/appkit.rs +++ b/crates/platform/src/appkit.rs @@ -419,6 +419,28 @@ fn register_we_view_class() { c"v@:@", ); + // scrollWheel: — call scroll handler with delta and mouse location + extern "C" fn scroll_wheel(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { + let dx: f64 = msg_send![event, scrollingDeltaX]; + let dy: f64 = msg_send![event, scrollingDeltaY]; + let raw_loc: NSPoint = msg_send![event, locationInWindow]; + let loc: NSPoint = + msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; + // SAFETY: We are on the main thread (AppKit event loop). + unsafe { + if let Some(handler) = SCROLL_HANDLER { + handler(dx, dy, loc.x, loc.y); + } + } + } + + let sel = Sel::register(c"scrollWheel:"); + view_class.add_method( + sel, + unsafe { std::mem::transmute::<*const (), Imp>(scroll_wheel as *const ()) }, + c"v@:@", + ); + view_class.register(); } @@ -509,6 +531,25 @@ pub fn set_resize_handler(handler: fn(f64, f64)) { } } +/// Global scroll callback, called from `scrollWheel:` with the scroll +/// deltas (dx, dy) and mouse location (x, y) in view coordinates. +/// +/// # Safety +/// +/// Accessed only from the main thread (the AppKit event loop). +static mut SCROLL_HANDLER: Option = None; + +/// Register a function to be called when a scroll wheel event occurs. +/// +/// The handler receives `(delta_x, delta_y, mouse_x, mouse_y)`. +/// Only one handler can be active at a time. +pub fn set_scroll_handler(handler: fn(f64, f64, f64, f64)) { + // SAFETY: Called from the main thread before `app.run()`. + unsafe { + SCROLL_HANDLER = Some(handler); + } +} + // --------------------------------------------------------------------------- // Window delegate for handling resize and close events // --------------------------------------------------------------------------- diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d8c89c1..65b2197 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,10 +8,29 @@ use std::collections::HashMap; use we_css::values::Color; use we_dom::NodeId; use we_image::pixel::Image; -use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine}; +use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; use we_style::computed::{BorderStyle, Overflow, TextDecoration, Visibility}; use we_text::font::Font; +/// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. +pub type ScrollState = HashMap; + +/// Scroll bar track color (light gray). +const SCROLLBAR_TRACK_COLOR: Color = Color { + r: 230, + g: 230, + b: 230, + a: 255, +}; + +/// Scroll bar thumb color (darker gray). +const SCROLLBAR_THUMB_COLOR: Color = Color { + r: 160, + g: 160, + b: 160, + a: 255, +}; + /// A paint command in the display list. #[derive(Debug)] pub enum PaintCommand { @@ -57,24 +76,55 @@ pub type DisplayList = Vec; /// Walks the tree in depth-first pre-order (painter's order): /// backgrounds first, then borders, then text on top. pub fn build_display_list(tree: &LayoutTree) -> DisplayList { + build_display_list_with_scroll(tree, &HashMap::new()) +} + +/// Build a display list from a layout tree with scroll state. +/// +/// `page_scroll` is the viewport-level scroll offset (x, y) applied to all content. +/// `scroll_state` maps element NodeIds to their per-element scroll offsets. +pub fn build_display_list_with_scroll( + tree: &LayoutTree, + scroll_state: &ScrollState, +) -> DisplayList { + let mut list = DisplayList::new(); + paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state); + list +} + +/// Build a display list with page-level scrolling. +/// +/// `page_scroll_y` shifts all content vertically (viewport-level scroll). +pub fn build_display_list_with_page_scroll( + tree: &LayoutTree, + page_scroll_y: f32, + scroll_state: &ScrollState, +) -> DisplayList { let mut list = DisplayList::new(); - paint_box(&tree.root, &mut list); + paint_box(&tree.root, &mut list, (0.0, -page_scroll_y), scroll_state); list } -fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { +fn paint_box( + layout_box: &LayoutBox, + list: &mut DisplayList, + translate: (f32, f32), + scroll_state: &ScrollState, +) { let visible = layout_box.visibility == Visibility::Visible; + let tx = translate.0; + let ty = translate.1; if visible { - paint_background(layout_box, list); - paint_borders(layout_box, list); + paint_background(layout_box, list, tx, ty); + paint_borders(layout_box, list, tx, ty); // Emit image paint command for replaced elements. if let Some((rw, rh)) = layout_box.replaced_size { if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { list.push(PaintCommand::DrawImage { - x: layout_box.rect.x, - y: layout_box.rect.y, + x: layout_box.rect.x + tx, + y: layout_box.rect.y + ty, width: rw, height: rh, node_id, @@ -82,29 +132,49 @@ fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { } } - paint_text(layout_box, list); + paint_text(layout_box, list, tx, ty); } + // Determine if this box is scrollable. + let scrollable = + layout_box.overflow == Overflow::Scroll || layout_box.overflow == Overflow::Auto; + // If this box has overflow clipping, push a clip rect for the padding box. let clips = layout_box.overflow != Overflow::Visible; if clips { let clip = padding_box(layout_box); list.push(PaintCommand::PushClip { - x: clip.x, - y: clip.y, + x: clip.x + tx, + y: clip.y + ty, width: clip.width, height: clip.height, }); } + // Compute child translate: adds scroll offset for scrollable boxes. + let mut child_translate = translate; + if scrollable { + if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { + if let Some(&(sx, sy)) = scroll_state.get(&node_id) { + child_translate.0 -= sx; + child_translate.1 -= sy; + } + } + } + // Always recurse into children — they may override visibility. for child in &layout_box.children { - paint_box(child, list); + paint_box(child, list, child_translate, scroll_state); } if clips { list.push(PaintCommand::PopClip); } + + // Paint scroll bars after PopClip so they're not clipped by the container. + if scrollable && visible { + paint_scrollbars(layout_box, list, tx, ty, scroll_state); + } } /// Compute the padding box rectangle for a layout box. @@ -127,7 +197,12 @@ fn node_id_from_box_type(box_type: &BoxType) -> Option { } } -fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { +/// Public variant of `node_id_from_box_type` for use by other crates. +pub fn node_id_from_box_type_pub(box_type: &BoxType) -> Option { + node_id_from_box_type(box_type) +} + +fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; // Only paint if the background is not fully transparent and the box has area. if bg.a == 0 { @@ -136,8 +211,8 @@ fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { if layout_box.rect.width > 0.0 && layout_box.rect.height > 0.0 { // Background covers the padding box (content + padding), not including border. list.push(PaintCommand::FillRect { - x: layout_box.rect.x, - y: layout_box.rect.y, + x: layout_box.rect.x + tx, + y: layout_box.rect.y + ty, width: layout_box.rect.width, height: layout_box.rect.height, color: bg, @@ -145,15 +220,15 @@ fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList) { } } -fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { +fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let b = &layout_box.border; let r = &layout_box.rect; let styles = &layout_box.border_styles; let colors = &layout_box.border_colors; // Border box starts at content origin minus padding and border. - let bx = r.x - layout_box.padding.left - b.left; - let by = r.y - layout_box.padding.top - b.top; + let bx = r.x - layout_box.padding.left - b.left + tx; + let by = r.y - layout_box.padding.top - b.top + ty; let bw = b.left + layout_box.padding.left + r.width + layout_box.padding.right + b.right; let bh = b.top + layout_box.padding.top + r.height + layout_box.padding.bottom + b.bottom; @@ -199,7 +274,7 @@ fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { } } -fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { +fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { for line in &layout_box.lines { let color = line.color; let font_size = line.font_size; @@ -208,18 +283,23 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { // before the text in painter's order. let glyph_idx = list.len(); + // Create a translated copy of the text line. + let mut translated_line = line.clone(); + translated_line.x += tx; + translated_line.y += ty; + list.push(PaintCommand::DrawGlyphs { - line: line.clone(), + line: translated_line, font_size, color, }); // Draw underline as a 1px line below the baseline. if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { - let baseline_y = line.y + font_size; + let baseline_y = line.y + ty + font_size; let underline_y = baseline_y + 2.0; list.push(PaintCommand::FillRect { - x: line.x, + x: line.x + tx, y: underline_y, width: line.width, height: 1.0, @@ -232,8 +312,8 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { list.insert( glyph_idx, PaintCommand::FillRect { - x: line.x, - y: line.y, + x: line.x + tx, + y: line.y + ty, width: line.width, height: font_size * 1.2, color: line.background_color, @@ -243,6 +323,72 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { } } +/// Paint scroll bars for a scrollable box. +fn paint_scrollbars( + layout_box: &LayoutBox, + list: &mut DisplayList, + tx: f32, + ty: f32, + scroll_state: &ScrollState, +) { + let pad = padding_box(layout_box); + let viewport_height = pad.height; + let content_height = layout_box.content_height; + + // Determine whether scroll bars should be shown. + let show_vertical = match layout_box.overflow { + Overflow::Scroll => true, + Overflow::Auto => content_height > viewport_height, + _ => false, + }; + + if !show_vertical || viewport_height <= 0.0 { + return; + } + + // Scroll bar track: right edge of the padding box. + let track_x = pad.x + pad.width - SCROLLBAR_WIDTH + tx; + let track_y = pad.y + ty; + let track_height = viewport_height; + + // Paint the track background. + list.push(PaintCommand::FillRect { + x: track_x, + y: track_y, + width: SCROLLBAR_WIDTH, + height: track_height, + color: SCROLLBAR_TRACK_COLOR, + }); + + // Compute thumb size and position. + let max_content = content_height.max(viewport_height); + let thumb_ratio = viewport_height / max_content; + let thumb_height = (thumb_ratio * track_height).max(20.0).min(track_height); + + // Get current scroll offset. + let scroll_y = node_id_from_box_type(&layout_box.box_type) + .and_then(|id| scroll_state.get(&id)) + .map(|&(_, sy)| sy) + .unwrap_or(0.0); + + let max_scroll = (content_height - viewport_height).max(0.0); + let scroll_ratio = if max_scroll > 0.0 { + scroll_y / max_scroll + } else { + 0.0 + }; + let thumb_y = track_y + scroll_ratio * (track_height - thumb_height); + + // Paint the thumb. + list.push(PaintCommand::FillRect { + x: track_x, + y: thumb_y, + width: SCROLLBAR_WIDTH, + height: thumb_height, + color: SCROLLBAR_THUMB_COLOR, + }); +} + /// An axis-aligned clip rectangle. #[derive(Debug, Clone, Copy)] struct ClipRect { @@ -336,7 +482,20 @@ impl Renderer { font: &Font, images: &HashMap, ) { - let display_list = build_display_list(layout_tree); + self.paint_with_scroll(layout_tree, font, images, 0.0, &HashMap::new()); + } + + /// Paint a layout tree with scroll state into the pixel buffer. + pub fn paint_with_scroll( + &mut self, + layout_tree: &LayoutTree, + font: &Font, + images: &HashMap, + page_scroll_y: f32, + scroll_state: &ScrollState, + ) { + let display_list = + build_display_list_with_page_scroll(layout_tree, page_scroll_y, scroll_state); for cmd in &display_list { match cmd { PaintCommand::FillRect { @@ -1294,4 +1453,218 @@ body { margin: 0; } "display:none element should not be in display list" ); } + + // --- Overflow scrolling tests --- + + #[test] + fn overflow_scroll_renders_scrollbar_always() { + // overflow:scroll should always render scroll bars, even when content fits. + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + // Should have scroll bar track and thumb FillRect commands. + let scrollbar_fills: Vec<_> = list + .iter() + .filter(|c| { + matches!(c, PaintCommand::FillRect { color, .. } + if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) + }) + .collect(); + + assert!( + scrollbar_fills.len() >= 2, + "overflow:scroll should render track and thumb (got {} fills)", + scrollbar_fills.len() + ); + } + + #[test] + fn overflow_auto_scrollbar_only_when_overflows() { + // overflow:auto with content that fits should NOT show scroll bars. + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let scrollbar_fills = list.iter().filter(|c| { + matches!(c, PaintCommand::FillRect { color, .. } + if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) + }); + + assert_eq!( + scrollbar_fills.count(), + 0, + "overflow:auto should not render scroll bars when content fits" + ); + } + + #[test] + fn overflow_auto_scrollbar_when_content_overflows() { + // overflow:auto with content taller than container should show scroll bars. + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let scrollbar_fills: Vec<_> = list + .iter() + .filter(|c| { + matches!(c, PaintCommand::FillRect { color, .. } + if *color == SCROLLBAR_TRACK_COLOR || *color == SCROLLBAR_THUMB_COLOR) + }) + .collect(); + + assert!( + scrollbar_fills.len() >= 2, + "overflow:auto should render scroll bars when content overflows (got {} fills)", + scrollbar_fills.len() + ); + } + + #[test] + fn scroll_offset_shifts_content() { + // Scrolling should shift content within a scroll container. + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let tree = layout_doc(&doc); + + // Find the container's NodeId to set scroll offset. + let container_id = tree + .root + .iter() + .find_map(|b| { + if b.overflow == Overflow::Scroll { + node_id_from_box_type(&b.box_type) + } else { + None + } + }) + .expect("should find scroll container"); + + // Without scroll: pixel at (50, 50) should be red (inside child). + let mut renderer = Renderer::new(300, 300); + renderer.paint(&tree, &font, &HashMap::new()); + let pixels = renderer.pixels(); + let offset_50_50 = ((50 * 300 + 50) * 4) as usize; + assert_eq!( + pixels[offset_50_50 + 2], + 255, + "before scroll: (50,50) should be red" + ); + + // With scroll offset 60px: content shifts up, so pixel at (50, 50) should + // still be red (content extends to 500px), but pixel at (50, 0) should now + // show content that was at y=60. + let mut scroll_state = HashMap::new(); + scroll_state.insert(container_id, (0.0, 60.0)); + let mut renderer2 = Renderer::new(300, 300); + renderer2.paint_with_scroll(&tree, &font, &HashMap::new(), 0.0, &scroll_state); + let pixels2 = renderer2.pixels(); + + // After scrolling 60px, content starts at y=-60 in the viewport. + // The container clips to its bounds, so pixel at (50, 50) is still visible + // red content (it was at y=110 in original content, now at y=50). + assert_eq!( + pixels2[offset_50_50 + 2], + 255, + "after scroll: (50,50) should still be red (content is tall)" + ); + } + + #[test] + fn scroll_bar_thumb_proportional() { + // Thumb size should be proportional to viewport/content ratio. + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + // Find the thumb FillRect (SCROLLBAR_THUMB_COLOR). + let thumb = list.iter().find(|c| { + matches!(c, PaintCommand::FillRect { color, .. } if *color == SCROLLBAR_THUMB_COLOR) + }); + + assert!(thumb.is_some(), "should have a scroll bar thumb"); + if let Some(PaintCommand::FillRect { height, .. }) = thumb { + // Container height is 100, content is 400. + // Thumb ratio = 100/400 = 0.25, track height = 100. + // Thumb height = max(0.25 * 100, 20) = 25. + assert!( + *height >= 20.0 && *height <= 100.0, + "thumb height {} should be proportional and within bounds", + height + ); + } + } + + #[test] + fn page_scroll_shifts_all_content() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let tree = layout_doc(&doc); + + // Without page scroll: red at (50, 50). + let mut r1 = Renderer::new(200, 200); + r1.paint(&tree, &font, &HashMap::new()); + let offset = ((50 * 200 + 50) * 4) as usize; + assert_eq!(r1.pixels()[offset + 2], 255, "should be red without scroll"); + + // With page scroll 80px: the red block shifts up by 80px. + // Pixel at (50, 50) was at y=130 in content, which is beyond the 100px block. + let mut r2 = Renderer::new(200, 200); + r2.paint_with_scroll(&tree, &font, &HashMap::new(), 80.0, &HashMap::new()); + let pixels2 = r2.pixels(); + // At (50, 50) with 80px scroll: this shows content at y=130, which is white. + assert_eq!( + pixels2[offset], 255, + "should be white at (50,50) with 80px page scroll" + ); + + // Pixel at (50, 10) with 80px scroll shows content at y=90, which is still red. + let offset_10 = ((10 * 200 + 50) * 4) as usize; + assert_eq!( + pixels2[offset_10 + 2], + 255, + "should be red at (50,10) with 80px page scroll" + ); + } }