diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 50144a3..adedccd 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -622,7 +622,10 @@ fn establishes_bfc(b: &LayoutBox) -> bool { /// Returns `true` if a block box has no in-flow content (empty block). fn is_empty_block(b: &LayoutBox) -> bool { - b.children.is_empty() && b.lines.is_empty() && b.replaced_size.is_none() + b.children.is_empty() + && b.lines.is_empty() + && b.replaced_size.is_none() + && matches!(b.css_height, LengthOrAuto::Auto) } /// Pre-collapse parent-child margins (CSS2 §8.3.1). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9101f51..d8c89c1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,8 +8,8 @@ use std::collections::HashMap; use we_css::values::Color; use we_dom::NodeId; use we_image::pixel::Image; -use we_layout::{BoxType, LayoutBox, LayoutTree, TextLine}; -use we_style::computed::{BorderStyle, TextDecoration, Visibility}; +use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine}; +use we_style::computed::{BorderStyle, Overflow, TextDecoration, Visibility}; use we_text::font::Font; /// A paint command in the display list. @@ -37,6 +37,16 @@ pub enum PaintCommand { height: f32, node_id: NodeId, }, + /// Push a clip rectangle onto the clip stack. All subsequent paint + /// commands are clipped to the intersection of all active clip rects. + PushClip { + x: f32, + y: f32, + width: f32, + height: f32, + }, + /// Pop the most recent clip rectangle off the clip stack. + PopClip, } /// A flat list of paint commands in painter's order. @@ -75,10 +85,37 @@ fn paint_box(layout_box: &LayoutBox, list: &mut DisplayList) { paint_text(layout_box, list); } + // 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, + width: clip.width, + height: clip.height, + }); + } + // Always recurse into children — they may override visibility. for child in &layout_box.children { paint_box(child, list); } + + if clips { + list.push(PaintCommand::PopClip); + } +} + +/// Compute the padding box rectangle for a layout box. +/// The padding box is the content area expanded by padding. +fn padding_box(layout_box: &LayoutBox) -> Rect { + Rect { + x: layout_box.rect.x - layout_box.padding.left, + y: layout_box.rect.y - layout_box.padding.top, + width: layout_box.rect.width + layout_box.padding.left + layout_box.padding.right, + height: layout_box.rect.height + layout_box.padding.top + layout_box.padding.bottom, + } } /// Extract the NodeId from a BoxType, if it has one. @@ -206,12 +243,40 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { } } +/// An axis-aligned clip rectangle. +#[derive(Debug, Clone, Copy)] +struct ClipRect { + x0: f32, + y0: f32, + x1: f32, + y1: f32, +} + +impl ClipRect { + /// Intersect two clip rects, returning the overlapping region. + /// Returns None if they don't overlap. + fn intersect(self, other: ClipRect) -> Option { + let x0 = self.x0.max(other.x0); + let y0 = self.y0.max(other.y0); + let x1 = self.x1.min(other.x1); + let y1 = self.y1.min(other.y1); + if x0 < x1 && y0 < y1 { + Some(ClipRect { x0, y0, x1, y1 }) + } else { + None + } + } +} + /// Software renderer that paints a display list into a BGRA pixel buffer. pub struct Renderer { width: u32, height: u32, /// BGRA pixel data, row-major, top-to-bottom. buffer: Vec, + /// Stack of clip rectangles. When non-empty, all drawing is clipped to + /// the intersection of all active clip rects. + clip_stack: Vec, } impl Renderer { @@ -231,9 +296,39 @@ impl Renderer { width, height, buffer, + clip_stack: Vec::new(), } } + /// Compute the effective clip rect from the clip stack. + /// Returns None if the clip stack is empty (no clipping). + fn active_clip(&self) -> Option { + if self.clip_stack.is_empty() { + return None; + } + // Start with the full buffer as the initial rect, then intersect. + let mut result = ClipRect { + x0: 0.0, + y0: 0.0, + x1: self.width as f32, + y1: self.height as f32, + }; + for clip in &self.clip_stack { + match result.intersect(*clip) { + Some(r) => result = r, + None => { + return Some(ClipRect { + x0: 0.0, + y0: 0.0, + x1: 0.0, + y1: 0.0, + }) + } + } + } + Some(result) + } + /// Paint a layout tree into the pixel buffer. pub fn paint( &mut self, @@ -271,6 +366,22 @@ impl Renderer { self.draw_image(*x, *y, *width, *height, image); } } + PaintCommand::PushClip { + x, + y, + width, + height, + } => { + self.clip_stack.push(ClipRect { + x0: *x, + y0: *y, + x1: *x + *width, + y1: *y + *height, + }); + } + PaintCommand::PopClip => { + self.clip_stack.pop(); + } } } } @@ -292,10 +403,26 @@ impl Renderer { /// Fill a rectangle with a solid color. pub fn fill_rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) { - let x0 = (x as i32).max(0) as u32; - let y0 = (y as i32).max(0) as u32; - let x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; - let y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; + let mut fx0 = x; + let mut fy0 = y; + let mut fx1 = x + width; + let mut fy1 = y + height; + + // Apply clip rect if active. + if let Some(clip) = self.active_clip() { + fx0 = fx0.max(clip.x0); + fy0 = fy0.max(clip.y0); + fx1 = fx1.min(clip.x1); + fy1 = fy1.min(clip.y1); + if fx0 >= fx1 || fy0 >= fy1 { + return; + } + } + + let x0 = (fx0 as i32).max(0) as u32; + let y0 = (fy0 as i32).max(0) as u32; + let x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; + let y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; if color.a == 255 { // Fully opaque — direct write. @@ -354,6 +481,7 @@ impl Renderer { ) { let x0 = x as i32; let y0 = y as i32; + let clip = self.active_clip(); for by in 0..bitmap.height { for bx in 0..bitmap.width { @@ -364,6 +492,17 @@ impl Renderer { continue; } + // Apply clip rect. + if let Some(ref c) = clip { + if (px as f32) < c.x0 + || (px as f32) >= c.x1 + || (py as f32) < c.y0 + || (py as f32) >= c.y1 + { + continue; + } + } + let coverage = bitmap.data[(by * bitmap.width + bx) as usize]; if coverage == 0 { continue; @@ -404,10 +543,25 @@ impl Renderer { return; } - let dst_x0 = (x as i32).max(0) as u32; - let dst_y0 = (y as i32).max(0) as u32; - let dst_x1 = ((x + width) as i32).max(0).min(self.width as i32) as u32; - let dst_y1 = ((y + height) as i32).max(0).min(self.height as i32) as u32; + let mut fx0 = x; + let mut fy0 = y; + let mut fx1 = x + width; + let mut fy1 = y + height; + + if let Some(clip) = self.active_clip() { + fx0 = fx0.max(clip.x0); + fy0 = fy0.max(clip.y0); + fx1 = fx1.min(clip.x1); + fy1 = fy1.min(clip.y1); + if fx0 >= fx1 || fy0 >= fy1 { + return; + } + } + + let dst_x0 = (fx0 as i32).max(0) as u32; + let dst_y0 = (fy0 as i32).max(0) as u32; + let dst_x1 = (fx1 as i32).max(0).min(self.width as i32) as u32; + let dst_y1 = (fy1 as i32).max(0).min(self.height as i32) as u32; let scale_x = image.width as f32 / width; let scale_y = image.height as f32 / height; @@ -891,6 +1045,231 @@ div { visibility: collapse; background-color: blue; width: 50px; height: 50px; } ); } + // --- Overflow clipping tests --- + + #[test] + fn overflow_hidden_generates_clip_commands() { + let html_str = r#" + +

Content

"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let push_count = list + .iter() + .filter(|c| matches!(c, PaintCommand::PushClip { .. })) + .count(); + let pop_count = list + .iter() + .filter(|c| matches!(c, PaintCommand::PopClip)) + .count(); + + assert!(push_count >= 1, "overflow:hidden should emit PushClip"); + assert_eq!(push_count, pop_count, "PushClip/PopClip must be balanced"); + } + + #[test] + fn overflow_visible_no_clip_commands() { + let html_str = r#" + +

Content

"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let push_count = list + .iter() + .filter(|c| matches!(c, PaintCommand::PushClip { .. })) + .count(); + + assert_eq!( + push_count, 0, + "overflow:visible should not emit clip commands" + ); + } + + #[test] + fn overflow_hidden_clips_child_background() { + // A tall child inside a short overflow:hidden container. + // The child's red background should be clipped to the container's bounds. + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let tree = layout_doc(&doc); + let mut renderer = Renderer::new(200, 200); + renderer.paint(&tree, &font, &HashMap::new()); + + let pixels = renderer.pixels(); + + // Pixel at (50, 25) — inside the container — should be red. + let inside_offset = ((25 * 200 + 50) * 4) as usize; + assert_eq!(pixels[inside_offset], 0, "B inside should be 0 (red)"); + assert_eq!( + pixels[inside_offset + 2], + 255, + "R inside should be 255 (red)" + ); + + // Pixel at (50, 100) — outside the container (below 50px) — should be white. + let outside_offset = ((100 * 200 + 50) * 4) as usize; + assert_eq!( + pixels[outside_offset], 255, + "B outside should be 255 (white)" + ); + assert_eq!( + pixels[outside_offset + 1], + 255, + "G outside should be 255 (white)" + ); + assert_eq!( + pixels[outside_offset + 2], + 255, + "R outside should be 255 (white)" + ); + } + + #[test] + fn overflow_auto_clips_like_hidden() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let tree = layout_doc(&doc); + let mut renderer = Renderer::new(200, 200); + renderer.paint(&tree, &font, &HashMap::new()); + + let pixels = renderer.pixels(); + + // Pixel at (50, 100) — below the container — should be white (clipped). + let outside_offset = ((100 * 200 + 50) * 4) as usize; + assert_eq!( + pixels[outside_offset], 255, + "overflow:auto should clip content below container" + ); + } + + #[test] + fn overflow_scroll_clips_like_hidden() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let tree = layout_doc(&doc); + let mut renderer = Renderer::new(200, 200); + renderer.paint(&tree, &font, &HashMap::new()); + + let pixels = renderer.pixels(); + + // Pixel at (50, 100) — below the container — should be white (clipped). + let outside_offset = ((100 * 200 + 50) * 4) as usize; + assert_eq!( + pixels[outside_offset], 255, + "overflow:scroll should clip content below container" + ); + } + + #[test] + fn nested_overflow_clips_intersect() { + // Outer: 200x200, inner: 100x100, both overflow:hidden. + // A large red child should only appear in the inner 100x100 area. + let html_str = r#" + + +
+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let tree = layout_doc(&doc); + let mut renderer = Renderer::new(300, 300); + renderer.paint(&tree, &font, &HashMap::new()); + + let pixels = renderer.pixels(); + + // Pixel at (50, 50) — inside inner container — should be red. + let inside_offset = ((50 * 300 + 50) * 4) as usize; + assert_eq!(pixels[inside_offset + 2], 255, "should be red inside inner"); + + // Pixel at (150, 50) — inside outer but outside inner — should be white. + let between_offset = ((50 * 300 + 150) * 4) as usize; + assert_eq!( + pixels[between_offset], 255, + "should be white outside inner clip" + ); + + // Pixel at (250, 50) — outside both — should be white. + let outside_offset = ((50 * 300 + 250) * 4) as usize; + assert_eq!( + pixels[outside_offset], 255, + "should be white outside both clips" + ); + } + + #[test] + fn clip_rect_intersect_basic() { + let a = ClipRect { + x0: 0.0, + y0: 0.0, + x1: 100.0, + y1: 100.0, + }; + let b = ClipRect { + x0: 50.0, + y0: 50.0, + x1: 150.0, + y1: 150.0, + }; + let c = a.intersect(b).unwrap(); + assert_eq!(c.x0, 50.0); + assert_eq!(c.y0, 50.0); + assert_eq!(c.x1, 100.0); + assert_eq!(c.y1, 100.0); + } + + #[test] + fn clip_rect_no_overlap() { + let a = ClipRect { + x0: 0.0, + y0: 0.0, + x1: 50.0, + y1: 50.0, + }; + let b = ClipRect { + x0: 100.0, + y0: 100.0, + x1: 200.0, + y1: 200.0, + }; + assert!(a.intersect(b).is_none()); + } + #[test] fn display_none_not_in_display_list() { let html_str = r#"