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 sticky positioning (position: sticky)

- Add Sticky variant to Position enum and parse "sticky" keyword
- Sticky elements participate in normal flow (like relative positioning)
- Store sticky_constraint rect on LayoutBox (parent's content area)
- Compute sticky paint-time offset in render crate based on scroll state
- Support top stickiness with containing block constraint clamping
- Update scroll container reference point tracking through paint recursion
- Add tests: parsing, normal flow layout, constraint rect, scroll sticking,
and parent constraint behavior

Implements issue 3mhlhoituxu2u

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

+400 -8
+125 -1
crates/layout/src/lib.rs
··· 112 112 pub css_margin: [LengthOrAuto; 4], 113 113 /// CSS padding values (may contain percentages for layout resolution). 114 114 pub css_padding: [LengthOrAuto; 4], 115 - /// CSS position offset values (top, right, bottom, left) for relative positioning. 115 + /// CSS position offset values (top, right, bottom, left) for relative/sticky positioning. 116 116 pub css_offsets: [LengthOrAuto; 4], 117 + /// For `position: sticky`: the containing block's content rect in document 118 + /// coordinates. Paint-time logic clamps the element within this rectangle. 119 + pub sticky_constraint: Option<Rect>, 117 120 /// CSS `visibility` property. 118 121 pub visibility: Visibility, 119 122 /// Natural content height before CSS height override. ··· 185 188 style.padding_left, 186 189 ], 187 190 css_offsets: [style.top, style.right, style.bottom, style.left], 191 + sticky_constraint: None, 188 192 visibility: style.visibility, 189 193 content_height: 0.0, 190 194 flex_direction: style.flex_direction, ··· 580 584 if let Some((rw, rh)) = b.replaced_size { 581 585 b.rect.width = rw.min(b.rect.width); 582 586 b.rect.height = rh; 587 + set_sticky_constraints(b); 583 588 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 584 589 apply_relative_offset(b, available_width, viewport_height); 585 590 return; ··· 626 631 LengthOrAuto::Auto => {} 627 632 } 628 633 634 + // Set sticky constraint rects now that this box's dimensions are final. 635 + set_sticky_constraints(b); 636 + 629 637 // Layout absolutely and fixed positioned children after this box's 630 638 // dimensions are fully resolved. 631 639 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); ··· 633 641 apply_relative_offset(b, available_width, viewport_height); 634 642 } 635 643 644 + /// For each direct child with `position: sticky`, record the parent's content 645 + /// rect as the sticky constraint rectangle. This is called after the parent's 646 + /// dimensions are fully resolved so that the rect is accurate. 647 + fn set_sticky_constraints(parent: &mut LayoutBox) { 648 + let content_rect = parent.rect; 649 + for child in &mut parent.children { 650 + if child.position == Position::Sticky { 651 + child.sticky_constraint = Some(content_rect); 652 + } 653 + } 654 + } 655 + 636 656 /// Apply `position: relative` offset to a box and all its descendants. 637 657 /// 638 658 /// Resolves the CSS position offsets (which may contain percentages) and ··· 916 936 if dy != 0.0 { 917 937 shift_box(child, 0.0, dy); 918 938 } 939 + 940 + // Set sticky constraints now that this child's dimensions are final. 941 + set_sticky_constraints(child); 919 942 920 943 // Recursively lay out any absolutely positioned grandchildren. 921 944 let new_abs_cb = if child.position != Position::Static { ··· 4614 4637 "abs y with bottom should be {}, got {}", 4615 4638 expected_y, 4616 4639 abs_box.rect.y, 4640 + ); 4641 + } 4642 + 4643 + // ----------------------------------------------------------------------- 4644 + // Sticky positioning tests 4645 + // ----------------------------------------------------------------------- 4646 + 4647 + #[test] 4648 + fn sticky_element_laid_out_in_normal_flow() { 4649 + // A sticky element should participate in normal flow just like a 4650 + // static or relative element — its siblings should be positioned 4651 + // as if sticky doesn't exist. 4652 + let mut doc = Document::new(); 4653 + let (_, _, body) = make_html_body(&mut doc); 4654 + let container = doc.create_element("div"); 4655 + let before = doc.create_element("div"); 4656 + let sticky = doc.create_element("div"); 4657 + let after = doc.create_element("div"); 4658 + doc.append_child(body, container); 4659 + doc.append_child(container, before); 4660 + doc.append_child(container, sticky); 4661 + doc.append_child(container, after); 4662 + 4663 + doc.set_attribute(container, "style", "width: 400px;"); 4664 + doc.set_attribute(before, "style", "height: 50px;"); 4665 + doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 30px;"); 4666 + doc.set_attribute(after, "style", "height: 60px;"); 4667 + 4668 + let tree = layout_doc(&doc); 4669 + let body_box = &tree.root.children[0]; 4670 + let container_box = &body_box.children[0]; 4671 + 4672 + // All three children should be present (sticky is in-flow). 4673 + let in_flow: Vec<&LayoutBox> = container_box.children.iter().collect(); 4674 + assert!( 4675 + in_flow.len() >= 3, 4676 + "expected 3 children, got {}", 4677 + in_flow.len() 4678 + ); 4679 + 4680 + let before_box = &in_flow[0]; 4681 + let sticky_box = &in_flow[1]; 4682 + let after_box = &in_flow[2]; 4683 + 4684 + assert_eq!(sticky_box.position, Position::Sticky); 4685 + 4686 + // Sticky element should be right after 'before' (at y = before.y + 50). 4687 + let expected_sticky_y = before_box.rect.y + 50.0; 4688 + assert!( 4689 + (sticky_box.rect.y - expected_sticky_y).abs() < 1.0, 4690 + "sticky y should be ~{}, got {}", 4691 + expected_sticky_y, 4692 + sticky_box.rect.y, 4693 + ); 4694 + 4695 + // 'after' should follow the sticky element (at y = sticky.y + 30). 4696 + let expected_after_y = sticky_box.rect.y + 30.0; 4697 + assert!( 4698 + (after_box.rect.y - expected_after_y).abs() < 1.0, 4699 + "after y should be ~{}, got {}", 4700 + expected_after_y, 4701 + after_box.rect.y, 4702 + ); 4703 + } 4704 + 4705 + #[test] 4706 + fn sticky_constraint_rect_is_set() { 4707 + // The layout engine should set `sticky_constraint` to the parent's 4708 + // content rect for sticky children. 4709 + let mut doc = Document::new(); 4710 + let (_, _, body) = make_html_body(&mut doc); 4711 + let container = doc.create_element("div"); 4712 + let sticky = doc.create_element("div"); 4713 + doc.append_child(body, container); 4714 + doc.append_child(container, sticky); 4715 + 4716 + doc.set_attribute(container, "style", "width: 400px; height: 300px;"); 4717 + doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 50px;"); 4718 + 4719 + let tree = layout_doc(&doc); 4720 + let body_box = &tree.root.children[0]; 4721 + let container_box = &body_box.children[0]; 4722 + let sticky_box = &container_box.children[0]; 4723 + 4724 + assert_eq!(sticky_box.position, Position::Sticky); 4725 + let constraint = sticky_box 4726 + .sticky_constraint 4727 + .expect("sticky element should have a constraint rect"); 4728 + 4729 + // Constraint should match the container's content rect. 4730 + assert!( 4731 + (constraint.width - container_box.rect.width).abs() < 0.01, 4732 + "constraint width should match container: {} vs {}", 4733 + constraint.width, 4734 + container_box.rect.width, 4735 + ); 4736 + assert!( 4737 + (constraint.height - container_box.rect.height).abs() < 0.01, 4738 + "constraint height should match container: {} vs {}", 4739 + constraint.height, 4740 + container_box.rect.height, 4617 4741 ); 4618 4742 } 4619 4743 }
+257 -7
crates/render/src/lib.rs
··· 9 9 use we_dom::NodeId; 10 10 use we_image::pixel::Image; 11 11 use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; 12 - use we_style::computed::{BorderStyle, Overflow, Position, TextDecoration, Visibility}; 12 + use we_style::computed::{ 13 + BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, 14 + }; 13 15 use we_text::font::Font; 14 16 15 17 /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. ··· 88 90 scroll_state: &ScrollState, 89 91 ) -> DisplayList { 90 92 let mut list = DisplayList::new(); 91 - paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state); 93 + paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state, 0.0); 92 94 list 93 95 } 94 96 ··· 101 103 scroll_state: &ScrollState, 102 104 ) -> DisplayList { 103 105 let mut list = DisplayList::new(); 104 - paint_box(&tree.root, &mut list, (0.0, -page_scroll_y), scroll_state); 106 + paint_box( 107 + &tree.root, 108 + &mut list, 109 + (0.0, -page_scroll_y), 110 + scroll_state, 111 + 0.0, 112 + ); 105 113 list 106 114 } 107 115 ··· 115 123 list: &mut DisplayList, 116 124 translate: (f32, f32), 117 125 scroll_state: &ScrollState, 126 + sticky_ref_screen_y: f32, 118 127 ) { 119 128 let visible = layout_box.visibility == Visibility::Visible; 120 129 let tx = translate.0; ··· 158 167 159 168 // Compute child translate: adds scroll offset for scrollable boxes. 160 169 let mut child_translate = translate; 170 + // When entering a scroll container, update the sticky reference point 171 + // to the container's padding box top in screen coordinates (pre-scroll). 172 + let mut child_sticky_ref = sticky_ref_screen_y; 161 173 if scrollable { 174 + // The scroll container's padding box top on screen (before scroll). 175 + child_sticky_ref = (layout_box.rect.y - layout_box.padding.top) + translate.1; 162 176 if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 163 177 if let Some(&(sx, sy)) = scroll_state.get(&node_id) { 164 178 child_translate.0 -= sx; ··· 197 211 198 212 // Paint negative z-index positioned children. 199 213 for &i in &negative_z { 200 - paint_box(&layout_box.children[i], list, child_translate, scroll_state); 214 + paint_child( 215 + &layout_box.children[i], 216 + list, 217 + child_translate, 218 + scroll_state, 219 + child_sticky_ref, 220 + ); 201 221 } 202 222 203 223 // Paint in-flow children in tree order. 204 224 for child in &layout_box.children { 205 225 if !is_positioned(child) { 206 - paint_box(child, list, child_translate, scroll_state); 226 + paint_child(child, list, child_translate, scroll_state, child_sticky_ref); 207 227 } 208 228 } 209 229 210 230 // Paint non-negative z-index positioned children. 211 231 for &i in &non_negative_z { 212 - paint_box(&layout_box.children[i], list, child_translate, scroll_state); 232 + paint_child( 233 + &layout_box.children[i], 234 + list, 235 + child_translate, 236 + scroll_state, 237 + child_sticky_ref, 238 + ); 213 239 } 214 240 } else { 215 241 // No positioned children — paint all in tree order. 216 242 for child in &layout_box.children { 217 - paint_box(child, list, child_translate, scroll_state); 243 + paint_child(child, list, child_translate, scroll_state, child_sticky_ref); 218 244 } 219 245 } 220 246 ··· 226 252 if scrollable && visible { 227 253 paint_scrollbars(layout_box, list, tx, ty, scroll_state); 228 254 } 255 + } 256 + 257 + /// Paint a child box, applying sticky positioning offset when needed. 258 + fn paint_child( 259 + child: &LayoutBox, 260 + list: &mut DisplayList, 261 + child_translate: (f32, f32), 262 + scroll_state: &ScrollState, 263 + sticky_ref_screen_y: f32, 264 + ) { 265 + if child.position == Position::Sticky { 266 + let adjusted = compute_sticky_translate(child, child_translate, sticky_ref_screen_y); 267 + paint_box(child, list, adjusted, scroll_state, sticky_ref_screen_y); 268 + } else { 269 + paint_box( 270 + child, 271 + list, 272 + child_translate, 273 + scroll_state, 274 + sticky_ref_screen_y, 275 + ); 276 + } 277 + } 278 + 279 + /// Resolve a `LengthOrAuto` to an optional pixel value for sticky offsets. 280 + fn resolve_sticky_px(value: LengthOrAuto, reference: f32) -> Option<f32> { 281 + match value { 282 + LengthOrAuto::Length(v) => Some(v), 283 + LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), 284 + LengthOrAuto::Auto => None, 285 + } 286 + } 287 + 288 + /// Compute the adjusted translate for a `position: sticky` element. 289 + /// 290 + /// The element is clamped so that its margin box stays within its 291 + /// `sticky_constraint` rectangle while honouring the CSS offset thresholds 292 + /// (`top`, `bottom`, `left`, `right`). 293 + fn compute_sticky_translate( 294 + child: &LayoutBox, 295 + child_translate: (f32, f32), 296 + sticky_ref_screen_y: f32, 297 + ) -> (f32, f32) { 298 + let constraint = match child.sticky_constraint { 299 + Some(c) => c, 300 + None => return child_translate, 301 + }; 302 + 303 + let [css_top, _css_right, css_bottom, _css_left] = child.css_offsets; 304 + 305 + let mut delta_y = 0.0f32; 306 + 307 + let margin_top_doc = child.rect.y - child.padding.top - child.border.top - child.margin.top; 308 + let margin_bottom_doc = child.rect.y 309 + + child.rect.height 310 + + child.padding.bottom 311 + + child.border.bottom 312 + + child.margin.bottom; 313 + let margin_top_screen = margin_top_doc + child_translate.1; 314 + let margin_bottom_screen = margin_bottom_doc + child_translate.1; 315 + let constraint_top_screen = constraint.y + child_translate.1; 316 + let constraint_bottom_screen = (constraint.y + constraint.height) + child_translate.1; 317 + 318 + // Handle `top` stickiness: push the element down so its margin box top 319 + // is at least `sticky_ref_screen_y + top`. 320 + if let Some(top) = resolve_sticky_px(css_top, constraint.height) { 321 + let target = sticky_ref_screen_y + top; 322 + let raw_delta = (target - margin_top_screen).max(0.0); 323 + // Clamp so margin box bottom does not exceed constraint bottom. 324 + let max_delta = (constraint_bottom_screen - margin_bottom_screen).max(0.0); 325 + delta_y = raw_delta.min(max_delta); 326 + } 327 + 328 + // Handle `bottom` stickiness: pull the element up so its margin box 329 + // bottom does not go below `sticky_ref_screen_y + visible_height - bottom`. 330 + // Without a reliable visible-height at this point we approximate by clamping 331 + // against the constraint top. 332 + if let Some(bottom) = resolve_sticky_px(css_bottom, constraint.height) { 333 + // Bottom stickiness: the element should not scroll below the 334 + // visible area minus the bottom offset. The visible bottom is 335 + // approximated as constraint_bottom_screen. 336 + let target_bottom = constraint_bottom_screen - bottom; 337 + let raw = (margin_bottom_screen + delta_y - target_bottom).max(0.0); 338 + // Clamp so margin top does not go above constraint top. 339 + let max_up = (margin_top_screen + delta_y - constraint_top_screen).max(0.0); 340 + delta_y -= raw.min(max_up); 341 + } 342 + 343 + (child_translate.0, child_translate.1 + delta_y) 229 344 } 230 345 231 346 /// Compute the padding box rectangle for a layout box. ··· 1712 1827 255, 1713 1828 "should be red at (50,10) with 80px page scroll" 1714 1829 ); 1830 + } 1831 + 1832 + // ----------------------------------------------------------------------- 1833 + // Sticky positioning paint-time tests 1834 + // ----------------------------------------------------------------------- 1835 + 1836 + #[test] 1837 + fn sticky_sticks_to_top_when_scrolled() { 1838 + // A sticky element with top:0 inside a tall container should 1839 + // stick to the top of the viewport when page-scrolled past it. 1840 + let mut doc = Document::new(); 1841 + let root = doc.root(); 1842 + let html = doc.create_element("html"); 1843 + let body = doc.create_element("body"); 1844 + doc.append_child(root, html); 1845 + doc.append_child(html, body); 1846 + 1847 + // Container tall enough to scroll. 1848 + let container = doc.create_element("div"); 1849 + doc.append_child(body, container); 1850 + doc.set_attribute(container, "style", "width: 400px; height: 1000px;"); 1851 + 1852 + // Spacer pushes sticky down. 1853 + let spacer = doc.create_element("div"); 1854 + doc.append_child(container, spacer); 1855 + doc.set_attribute(spacer, "style", "height: 100px;"); 1856 + 1857 + // Sticky element. 1858 + let sticky = doc.create_element("div"); 1859 + let text = doc.create_text("Sticky"); 1860 + doc.append_child(container, sticky); 1861 + doc.append_child(sticky, text); 1862 + doc.set_attribute( 1863 + sticky, 1864 + "style", 1865 + "position: sticky; top: 0; height: 30px; background-color: red;", 1866 + ); 1867 + 1868 + let tree = layout_doc(&doc); 1869 + let scroll_state: ScrollState = HashMap::new(); 1870 + 1871 + // With no scroll, the display list should show the sticky at its 1872 + // normal flow position. 1873 + let list_no_scroll = build_display_list_with_page_scroll(&tree, 0.0, &scroll_state); 1874 + // Find the red FillRect — that's our sticky background. 1875 + let sticky_bg_no_scroll = list_no_scroll 1876 + .iter() 1877 + .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) 1878 + .expect("should find sticky red background"); 1879 + let no_scroll_y = match sticky_bg_no_scroll { 1880 + PaintCommand::FillRect { y, .. } => *y, 1881 + _ => unreachable!(), 1882 + }; 1883 + // Normal position: body has default 8px margin, spacer is 100px, 1884 + // so sticky should be around y≈108. 1885 + assert!( 1886 + no_scroll_y > 90.0, 1887 + "without scroll, sticky should be at its normal position, got y={}", 1888 + no_scroll_y, 1889 + ); 1890 + 1891 + // Now scroll 200px — the sticky element's normal position would be 1892 + // around 108 - 200 = -92 (off screen), but it should stick at y=0. 1893 + let list_scrolled = build_display_list_with_page_scroll(&tree, 200.0, &scroll_state); 1894 + let sticky_bg_scrolled = list_scrolled 1895 + .iter() 1896 + .find(|cmd| matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 255 && color.g == 0 && color.b == 0)) 1897 + .expect("should find sticky red background when scrolled"); 1898 + let scrolled_y = match sticky_bg_scrolled { 1899 + PaintCommand::FillRect { y, .. } => *y, 1900 + _ => unreachable!(), 1901 + }; 1902 + // The sticky element should be pinned near y=0 (content box y 1903 + // accounts for padding/border/margin). 1904 + assert!( 1905 + scrolled_y >= -1.0 && scrolled_y < 20.0, 1906 + "with 200px scroll, sticky should be near top (y≈0), got y={}", 1907 + scrolled_y, 1908 + ); 1909 + } 1910 + 1911 + #[test] 1912 + fn sticky_constrained_by_parent() { 1913 + // When the containing block scrolls past, the sticky element should 1914 + // unstick and scroll away with its container. 1915 + let mut doc = Document::new(); 1916 + let root = doc.root(); 1917 + let html = doc.create_element("html"); 1918 + let body = doc.create_element("body"); 1919 + doc.append_child(root, html); 1920 + doc.append_child(html, body); 1921 + 1922 + // Small container that will scroll off. 1923 + let container = doc.create_element("div"); 1924 + doc.append_child(body, container); 1925 + doc.set_attribute(container, "style", "width: 400px; height: 200px;"); 1926 + 1927 + // Sticky element inside. 1928 + let sticky = doc.create_element("div"); 1929 + let text = doc.create_text("Sticky"); 1930 + doc.append_child(container, sticky); 1931 + doc.append_child(sticky, text); 1932 + doc.set_attribute( 1933 + sticky, 1934 + "style", 1935 + "position: sticky; top: 0; height: 30px; background-color: green;", 1936 + ); 1937 + 1938 + // After container — more content so page can scroll further. 1939 + let after = doc.create_element("div"); 1940 + doc.append_child(body, after); 1941 + doc.set_attribute(after, "style", "height: 2000px;"); 1942 + 1943 + let tree = layout_doc(&doc); 1944 + let scroll_state: ScrollState = HashMap::new(); 1945 + 1946 + // Scroll far enough that the container is completely off-screen. 1947 + // Container is at roughly y=8 (body margin) with height 200, 1948 + // so bottom is at y≈208. Scroll by 400 should move it well off screen. 1949 + let list = build_display_list_with_page_scroll(&tree, 400.0, &scroll_state); 1950 + let sticky_bg = list.iter().find(|cmd| { 1951 + matches!(cmd, PaintCommand::FillRect { color, .. } if color.r == 0 && color.g == 128 && color.b == 0) 1952 + }); 1953 + 1954 + if let Some(PaintCommand::FillRect { y, .. }) = sticky_bg { 1955 + // The sticky element should have scrolled off with its container. 1956 + // Its screen y should be negative (off screen). 1957 + assert!( 1958 + y < &0.0, 1959 + "sticky should be off-screen when container scrolled away, got y={}", 1960 + y, 1961 + ); 1962 + } 1963 + // If not found, the element might not be painted (which is also 1964 + // acceptable if it's off-screen). 1715 1965 } 1716 1966 }
+18
crates/style/src/computed.rs
··· 37 37 Relative, 38 38 Absolute, 39 39 Fixed, 40 + Sticky, 40 41 } 41 42 42 43 // --------------------------------------------------------------------------- ··· 851 852 "relative" => Position::Relative, 852 853 "absolute" => Position::Absolute, 853 854 "fixed" => Position::Fixed, 855 + "sticky" => Position::Sticky, 854 856 _ => style.position, 855 857 }, 856 858 _ => style.position, ··· 2339 2341 let body = &styled.children[0]; 2340 2342 let div = &body.children[0]; 2341 2343 assert_eq!(div.style.z_index, None); 2344 + } 2345 + 2346 + #[test] 2347 + fn position_sticky_parsing() { 2348 + let html_str = r#"<!DOCTYPE html> 2349 + <html><head><style> 2350 + .s { position: sticky; top: 10px; } 2351 + </style></head> 2352 + <body><div class="s">Sticky</div></body></html>"#; 2353 + let doc = we_html::parse_html(html_str); 2354 + let sheets = extract_stylesheets(&doc); 2355 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2356 + let body = &styled.children[0]; 2357 + let div = &body.children[0]; 2358 + assert_eq!(div.style.position, Position::Sticky); 2359 + assert_eq!(div.style.top, LengthOrAuto::Length(10.0)); 2342 2360 } 2343 2361 }