diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 73560c6..558eb53 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -112,8 +112,11 @@ pub struct LayoutBox { pub css_margin: [LengthOrAuto; 4], /// CSS padding values (may contain percentages for layout resolution). pub css_padding: [LengthOrAuto; 4], - /// CSS position offset values (top, right, bottom, left) for relative positioning. + /// CSS position offset values (top, right, bottom, left) for relative/sticky positioning. pub css_offsets: [LengthOrAuto; 4], + /// For `position: sticky`: the containing block's content rect in document + /// coordinates. Paint-time logic clamps the element within this rectangle. + pub sticky_constraint: Option, /// CSS `visibility` property. pub visibility: Visibility, /// Natural content height before CSS height override. @@ -185,6 +188,7 @@ impl LayoutBox { style.padding_left, ], css_offsets: [style.top, style.right, style.bottom, style.left], + sticky_constraint: None, visibility: style.visibility, content_height: 0.0, flex_direction: style.flex_direction, @@ -580,6 +584,7 @@ fn compute_layout( if let Some((rw, rh)) = b.replaced_size { b.rect.width = rw.min(b.rect.width); b.rect.height = rh; + set_sticky_constraints(b); layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); apply_relative_offset(b, available_width, viewport_height); return; @@ -626,6 +631,9 @@ fn compute_layout( LengthOrAuto::Auto => {} } + // Set sticky constraint rects now that this box's dimensions are final. + set_sticky_constraints(b); + // Layout absolutely and fixed positioned children after this box's // dimensions are fully resolved. layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); @@ -633,6 +641,18 @@ fn compute_layout( apply_relative_offset(b, available_width, viewport_height); } +/// For each direct child with `position: sticky`, record the parent's content +/// rect as the sticky constraint rectangle. This is called after the parent's +/// dimensions are fully resolved so that the rect is accurate. +fn set_sticky_constraints(parent: &mut LayoutBox) { + let content_rect = parent.rect; + for child in &mut parent.children { + if child.position == Position::Sticky { + child.sticky_constraint = Some(content_rect); + } + } +} + /// Apply `position: relative` offset to a box and all its descendants. /// /// Resolves the CSS position offsets (which may contain percentages) and @@ -917,6 +937,9 @@ fn layout_absolute_child( shift_box(child, 0.0, dy); } + // Set sticky constraints now that this child's dimensions are final. + set_sticky_constraints(child); + // Recursively lay out any absolutely positioned grandchildren. let new_abs_cb = if child.position != Position::Static { padding_box_rect(child) @@ -4616,4 +4639,105 @@ body { margin: 0; } abs_box.rect.y, ); } + + // ----------------------------------------------------------------------- + // Sticky positioning tests + // ----------------------------------------------------------------------- + + #[test] + fn sticky_element_laid_out_in_normal_flow() { + // A sticky element should participate in normal flow just like a + // static or relative element — its siblings should be positioned + // as if sticky doesn't exist. + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let container = doc.create_element("div"); + let before = doc.create_element("div"); + let sticky = doc.create_element("div"); + let after = doc.create_element("div"); + doc.append_child(body, container); + doc.append_child(container, before); + doc.append_child(container, sticky); + doc.append_child(container, after); + + doc.set_attribute(container, "style", "width: 400px;"); + doc.set_attribute(before, "style", "height: 50px;"); + doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 30px;"); + doc.set_attribute(after, "style", "height: 60px;"); + + let tree = layout_doc(&doc); + let body_box = &tree.root.children[0]; + let container_box = &body_box.children[0]; + + // All three children should be present (sticky is in-flow). + let in_flow: Vec<&LayoutBox> = container_box.children.iter().collect(); + assert!( + in_flow.len() >= 3, + "expected 3 children, got {}", + in_flow.len() + ); + + let before_box = &in_flow[0]; + let sticky_box = &in_flow[1]; + let after_box = &in_flow[2]; + + assert_eq!(sticky_box.position, Position::Sticky); + + // Sticky element should be right after 'before' (at y = before.y + 50). + let expected_sticky_y = before_box.rect.y + 50.0; + assert!( + (sticky_box.rect.y - expected_sticky_y).abs() < 1.0, + "sticky y should be ~{}, got {}", + expected_sticky_y, + sticky_box.rect.y, + ); + + // 'after' should follow the sticky element (at y = sticky.y + 30). + let expected_after_y = sticky_box.rect.y + 30.0; + assert!( + (after_box.rect.y - expected_after_y).abs() < 1.0, + "after y should be ~{}, got {}", + expected_after_y, + after_box.rect.y, + ); + } + + #[test] + fn sticky_constraint_rect_is_set() { + // The layout engine should set `sticky_constraint` to the parent's + // content rect for sticky children. + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let container = doc.create_element("div"); + let sticky = doc.create_element("div"); + doc.append_child(body, container); + doc.append_child(container, sticky); + + doc.set_attribute(container, "style", "width: 400px; height: 300px;"); + doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 50px;"); + + let tree = layout_doc(&doc); + let body_box = &tree.root.children[0]; + let container_box = &body_box.children[0]; + let sticky_box = &container_box.children[0]; + + assert_eq!(sticky_box.position, Position::Sticky); + let constraint = sticky_box + .sticky_constraint + .expect("sticky element should have a constraint rect"); + + // Constraint should match the container's content rect. + assert!( + (constraint.width - container_box.rect.width).abs() < 0.01, + "constraint width should match container: {} vs {}", + constraint.width, + container_box.rect.width, + ); + assert!( + (constraint.height - container_box.rect.height).abs() < 0.01, + "constraint height should match container: {} vs {}", + constraint.height, + container_box.rect.height, + ); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 657fa18..e925f50 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,7 +9,9 @@ use we_css::values::Color; use we_dom::NodeId; use we_image::pixel::Image; use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; -use we_style::computed::{BorderStyle, Overflow, Position, TextDecoration, Visibility}; +use we_style::computed::{ + BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, +}; use we_text::font::Font; /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. @@ -88,7 +90,7 @@ pub fn build_display_list_with_scroll( scroll_state: &ScrollState, ) -> DisplayList { let mut list = DisplayList::new(); - paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state); + paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state, 0.0); list } @@ -101,7 +103,13 @@ pub fn build_display_list_with_page_scroll( scroll_state: &ScrollState, ) -> DisplayList { let mut list = DisplayList::new(); - paint_box(&tree.root, &mut list, (0.0, -page_scroll_y), scroll_state); + paint_box( + &tree.root, + &mut list, + (0.0, -page_scroll_y), + scroll_state, + 0.0, + ); list } @@ -115,6 +123,7 @@ fn paint_box( list: &mut DisplayList, translate: (f32, f32), scroll_state: &ScrollState, + sticky_ref_screen_y: f32, ) { let visible = layout_box.visibility == Visibility::Visible; let tx = translate.0; @@ -158,7 +167,12 @@ fn paint_box( // Compute child translate: adds scroll offset for scrollable boxes. let mut child_translate = translate; + // When entering a scroll container, update the sticky reference point + // to the container's padding box top in screen coordinates (pre-scroll). + let mut child_sticky_ref = sticky_ref_screen_y; if scrollable { + // The scroll container's padding box top on screen (before scroll). + child_sticky_ref = (layout_box.rect.y - layout_box.padding.top) + translate.1; 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; @@ -197,24 +211,36 @@ fn paint_box( // Paint negative z-index positioned children. for &i in &negative_z { - paint_box(&layout_box.children[i], list, child_translate, scroll_state); + paint_child( + &layout_box.children[i], + list, + child_translate, + scroll_state, + child_sticky_ref, + ); } // Paint in-flow children in tree order. for child in &layout_box.children { if !is_positioned(child) { - paint_box(child, list, child_translate, scroll_state); + paint_child(child, list, child_translate, scroll_state, child_sticky_ref); } } // Paint non-negative z-index positioned children. for &i in &non_negative_z { - paint_box(&layout_box.children[i], list, child_translate, scroll_state); + paint_child( + &layout_box.children[i], + list, + child_translate, + scroll_state, + child_sticky_ref, + ); } } else { // No positioned children — paint all in tree order. for child in &layout_box.children { - paint_box(child, list, child_translate, scroll_state); + paint_child(child, list, child_translate, scroll_state, child_sticky_ref); } } @@ -228,6 +254,95 @@ fn paint_box( } } +/// Paint a child box, applying sticky positioning offset when needed. +fn paint_child( + child: &LayoutBox, + list: &mut DisplayList, + child_translate: (f32, f32), + scroll_state: &ScrollState, + sticky_ref_screen_y: f32, +) { + if child.position == Position::Sticky { + let adjusted = compute_sticky_translate(child, child_translate, sticky_ref_screen_y); + paint_box(child, list, adjusted, scroll_state, sticky_ref_screen_y); + } else { + paint_box( + child, + list, + child_translate, + scroll_state, + sticky_ref_screen_y, + ); + } +} + +/// Resolve a `LengthOrAuto` to an optional pixel value for sticky offsets. +fn resolve_sticky_px(value: LengthOrAuto, reference: f32) -> Option { + match value { + LengthOrAuto::Length(v) => Some(v), + LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), + LengthOrAuto::Auto => None, + } +} + +/// Compute the adjusted translate for a `position: sticky` element. +/// +/// The element is clamped so that its margin box stays within its +/// `sticky_constraint` rectangle while honouring the CSS offset thresholds +/// (`top`, `bottom`, `left`, `right`). +fn compute_sticky_translate( + child: &LayoutBox, + child_translate: (f32, f32), + sticky_ref_screen_y: f32, +) -> (f32, f32) { + let constraint = match child.sticky_constraint { + Some(c) => c, + None => return child_translate, + }; + + let [css_top, _css_right, css_bottom, _css_left] = child.css_offsets; + + let mut delta_y = 0.0f32; + + let margin_top_doc = child.rect.y - child.padding.top - child.border.top - child.margin.top; + let margin_bottom_doc = child.rect.y + + child.rect.height + + child.padding.bottom + + child.border.bottom + + child.margin.bottom; + let margin_top_screen = margin_top_doc + child_translate.1; + let margin_bottom_screen = margin_bottom_doc + child_translate.1; + let constraint_top_screen = constraint.y + child_translate.1; + let constraint_bottom_screen = (constraint.y + constraint.height) + child_translate.1; + + // Handle `top` stickiness: push the element down so its margin box top + // is at least `sticky_ref_screen_y + top`. + if let Some(top) = resolve_sticky_px(css_top, constraint.height) { + let target = sticky_ref_screen_y + top; + let raw_delta = (target - margin_top_screen).max(0.0); + // Clamp so margin box bottom does not exceed constraint bottom. + let max_delta = (constraint_bottom_screen - margin_bottom_screen).max(0.0); + delta_y = raw_delta.min(max_delta); + } + + // Handle `bottom` stickiness: pull the element up so its margin box + // bottom does not go below `sticky_ref_screen_y + visible_height - bottom`. + // Without a reliable visible-height at this point we approximate by clamping + // against the constraint top. + if let Some(bottom) = resolve_sticky_px(css_bottom, constraint.height) { + // Bottom stickiness: the element should not scroll below the + // visible area minus the bottom offset. The visible bottom is + // approximated as constraint_bottom_screen. + let target_bottom = constraint_bottom_screen - bottom; + let raw = (margin_bottom_screen + delta_y - target_bottom).max(0.0); + // Clamp so margin top does not go above constraint top. + let max_up = (margin_top_screen + delta_y - constraint_top_screen).max(0.0); + delta_y -= raw.min(max_up); + } + + (child_translate.0, child_translate.1 + delta_y) +} + /// 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 { @@ -1713,4 +1828,139 @@ body { margin: 0; } "should be red at (50,10) with 80px page scroll" ); } + + // ----------------------------------------------------------------------- + // Sticky positioning paint-time tests + // ----------------------------------------------------------------------- + + #[test] + fn sticky_sticks_to_top_when_scrolled() { + // A sticky element with top:0 inside a tall container should + // stick to the top of the viewport when page-scrolled past it. + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + doc.append_child(root, html); + doc.append_child(html, body); + + // Container tall enough to scroll. + let container = doc.create_element("div"); + doc.append_child(body, container); + doc.set_attribute(container, "style", "width: 400px; height: 1000px;"); + + // Spacer pushes sticky down. + let spacer = doc.create_element("div"); + doc.append_child(container, spacer); + doc.set_attribute(spacer, "style", "height: 100px;"); + + // Sticky element. + let sticky = doc.create_element("div"); + let text = doc.create_text("Sticky"); + doc.append_child(container, sticky); + doc.append_child(sticky, text); + doc.set_attribute( + sticky, + "style", + "position: sticky; top: 0; height: 30px; background-color: red;", + ); + + let tree = layout_doc(&doc); + let scroll_state: ScrollState = HashMap::new(); + + // With no scroll, the display list should show the sticky at its + // normal flow position. + let list_no_scroll = build_display_list_with_page_scroll(&tree, 0.0, &scroll_state); + // Find the red FillRect — that's our sticky background. + let sticky_bg_no_scroll = list_no_scroll + .iter() + .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) + .expect("should find sticky red background"); + let no_scroll_y = match sticky_bg_no_scroll { + PaintCommand::FillRect { y, .. } => *y, + _ => unreachable!(), + }; + // Normal position: body has default 8px margin, spacer is 100px, + // so sticky should be around y≈108. + assert!( + no_scroll_y > 90.0, + "without scroll, sticky should be at its normal position, got y={}", + no_scroll_y, + ); + + // Now scroll 200px — the sticky element's normal position would be + // around 108 - 200 = -92 (off screen), but it should stick at y=0. + let list_scrolled = build_display_list_with_page_scroll(&tree, 200.0, &scroll_state); + let sticky_bg_scrolled = list_scrolled + .iter() + .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) + .expect("should find sticky red background when scrolled"); + let scrolled_y = match sticky_bg_scrolled { + PaintCommand::FillRect { y, .. } => *y, + _ => unreachable!(), + }; + // The sticky element should be pinned near y=0 (content box y + // accounts for padding/border/margin). + assert!( + scrolled_y >= -1.0 && scrolled_y < 20.0, + "with 200px scroll, sticky should be near top (y≈0), got y={}", + scrolled_y, + ); + } + + #[test] + fn sticky_constrained_by_parent() { + // When the containing block scrolls past, the sticky element should + // unstick and scroll away with its container. + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + doc.append_child(root, html); + doc.append_child(html, body); + + // Small container that will scroll off. + let container = doc.create_element("div"); + doc.append_child(body, container); + doc.set_attribute(container, "style", "width: 400px; height: 200px;"); + + // Sticky element inside. + let sticky = doc.create_element("div"); + let text = doc.create_text("Sticky"); + doc.append_child(container, sticky); + doc.append_child(sticky, text); + doc.set_attribute( + sticky, + "style", + "position: sticky; top: 0; height: 30px; background-color: green;", + ); + + // After container — more content so page can scroll further. + let after = doc.create_element("div"); + doc.append_child(body, after); + doc.set_attribute(after, "style", "height: 2000px;"); + + let tree = layout_doc(&doc); + let scroll_state: ScrollState = HashMap::new(); + + // Scroll far enough that the container is completely off-screen. + // Container is at roughly y=8 (body margin) with height 200, + // so bottom is at y≈208. Scroll by 400 should move it well off screen. + let list = build_display_list_with_page_scroll(&tree, 400.0, &scroll_state); + let sticky_bg = list.iter().find(|cmd| { + matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 0 && color.g == 128 && color.b == 0) + }); + + if let Some(PaintCommand::FillRect { y, .. }) = sticky_bg { + // The sticky element should have scrolled off with its container. + // Its screen y should be negative (off screen). + assert!( + y < &0.0, + "sticky should be off-screen when container scrolled away, got y={}", + y, + ); + } + // If not found, the element might not be painted (which is also + // acceptable if it's off-screen). + } } diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 8dedfd5..cc24a28 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -37,6 +37,7 @@ pub enum Position { Relative, Absolute, Fixed, + Sticky, } // --------------------------------------------------------------------------- @@ -851,6 +852,7 @@ fn apply_property( "relative" => Position::Relative, "absolute" => Position::Absolute, "fixed" => Position::Fixed, + "sticky" => Position::Sticky, _ => style.position, }, _ => style.position, @@ -2340,4 +2342,20 @@ mod tests { let div = &body.children[0]; assert_eq!(div.style.z_index, None); } + + #[test] + fn position_sticky_parsing() { + let html_str = r#" + +
Sticky
"#; + let doc = we_html::parse_html(html_str); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let body = &styled.children[0]; + let div = &body.children[0]; + assert_eq!(div.style.position, Position::Sticky); + assert_eq!(div.style.top, LengthOrAuto::Length(10.0)); + } }