diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 51d5f6e..6fb8e44 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -530,6 +530,18 @@ pub fn layout_dom_with_images( } } + // CSS Display blockification: absolute/fixed boxes and floats compute + // an inline outside display to block. This affects how their own mixed + // children form lines and blocks; they remain shrink-to-fit where the + // positioning/float algorithm requires it. + for style in styles.values_mut() { + if (matches!(style.position, Some(taffy::Position::Absolute)) || style.float.is_some()) + && style.display == crate::Display::Inline + { + style.display = crate::Display::Block; + } + } + // The initial containing block is modelled by the root taffy node's // definite viewport height (set at build), which is what `html,body{ // height:100%}` chains and sticky-footer app shells resolve against. @@ -563,6 +575,7 @@ pub fn layout_dom_with_images( } if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &mut words, &mut engine, &mut ifc_items, &styles) { + reparent_inset_positioned_nodes(tree, &mut taffy_tree, taffy_root, &id_map, &styles); let available = taffy::Size { width: taffy::AvailableSpace::Definite(viewport.0), height: taffy::AvailableSpace::Definite(viewport.1), @@ -1139,6 +1152,69 @@ fn compute_absolute_rects( } } +/// Attach inset-positioned boxes to their CSS containing block rather than +/// their immediate DOM parent. +/// +/// Taffy resolves an absolute child's insets against its direct layout-tree +/// parent. CSS instead uses the nearest non-static ancestor, or the initial +/// containing block for `position:fixed`. Gecko represents that distinction +/// by reparenting the absolute frame and leaving a placeholder in normal +/// flow. We can safely do the same without a placeholder when each axis has +/// at least one specified inset: no static-position coordinate is needed. +/// Boxes with an entirely auto axis stay under their DOM parent until the +/// placeholder/static-position path is implemented. +fn reparent_inset_positioned_nodes( + tree: &DomTree, + taffy_tree: &mut TaffyTree, + taffy_root: taffy::NodeId, + id_map: &HashMap, + styles: &HashMap, +) { + let reverse: HashMap = + id_map.iter().map(|(&taffy_id, &dom_id)| (dom_id, taffy_id)).collect(); + let mut nearest_cb_for_children: HashMap = HashMap::new(); + + for dom_id in tree.descendants(tree.document()) { + let Some(style) = styles.get(&dom_id) else { continue }; + let parent = tree.get_node(dom_id).and_then(|node| node.parent); + let inherited_cb = parent + .and_then(|id| nearest_cb_for_children.get(&id).copied()) + .unwrap_or(taffy_root); + + // Record this before any candidate early-exit so all descendants get + // an O(1) nearest-containing-block lookup. The full walk stays O(n) + // even on deeply nested or absolute-heavy documents. + let child_cb = if style.position.is_some() { + reverse.get(&dom_id).copied().unwrap_or(inherited_cb) + } else { + inherited_cb + }; + nearest_cb_for_children.insert(dom_id, child_cb); + + if !matches!(style.position, Some(taffy::Position::Absolute)) { + continue; + } + let has_block_inset = style.inset[0].is_some() || style.inset[2].is_some(); + let has_inline_inset = style.inset[1].is_some() || style.inset[3].is_some(); + if !has_block_inset || !has_inline_inset { + continue; + } + let Some(&child) = reverse.get(&dom_id) else { continue }; + let target = if style.position_fixed { + taffy_root + } else { + inherited_cb + }; + let Some(current) = taffy_tree.parent(child) else { continue }; + if current == target { + continue; + } + if taffy_tree.remove_child(current, child).is_ok() { + let _ = taffy_tree.add_child(target, child); + } + } +} + /// Does `id` have any direct child that is inline-level (a non-whitespace /// text node, or an element whose resolved display is `Inline`)? Used to /// decide whether a block container needs the flex-row-wrap approximation of diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index ac4f8e5..946cbd8 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -301,6 +301,9 @@ pub struct LayoutStyle { // Positioning. `position: absolute|fixed` takes the box out of normal flow. pub position: Option, + /// Distinguishes `fixed` from `absolute`; both map to taffy's absolute + /// layout mode, but fixed boxes use the initial containing block. + pub position_fixed: bool, pub inset: [Option; 4], // top, right, bottom, left /// `overflow`/-x/-y other than `visible`: clips this element's descendants diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index ca25c1f..9633231 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -434,8 +434,22 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "flex" => parse_flex_shorthand(style, value), "position" => { match value { - "absolute" | "fixed" => style.position = Some(taffy::Position::Absolute), - "relative" | "sticky" | "static" => style.position = Some(taffy::Position::Relative), + "absolute" => { + style.position = Some(taffy::Position::Absolute); + style.position_fixed = false; + } + "fixed" => { + style.position = Some(taffy::Position::Absolute); + style.position_fixed = true; + } + "relative" | "sticky" => { + style.position = Some(taffy::Position::Relative); + style.position_fixed = false; + } + "static" => { + style.position = None; + style.position_fixed = false; + } _ => {} } }, diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index b3435d6..2c6a577 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -122,6 +122,45 @@ fn percentage_padding_top_reserves_aspect_ratio_box() { ); } +#[test] +fn inset_absolute_uses_nearest_positioned_ancestor() { + let html = r##" + +
+
+ +
+
+
+
+
+ + "##; + let tree = parse_html(html); + let layout = layout_dom(&tree, (900.0, 700.0)); + let cb = layout.rects[&tree.get_element_by_id("cb").expect("containing block")]; + let abs = layout.rects[&tree.get_element_by_id("abs").expect("absolute child")]; + let abs_end = layout.rects[&tree.get_element_by_id("abs-end").expect("end-inset child")]; + let abs_percent = layout.rects[&tree.get_element_by_id("abs-percent").expect("percent-inset child")]; + let fixed = layout.rects[&tree.get_element_by_id("fixed").expect("fixed child")]; + + // Absolute insets are measured from the positioned ancestor's padding + // edge, not from the intervening static wrapper. + assert!((abs.x - cb.x - 25.0).abs() < 1.0, "wrong absolute x: cb={cb:?} abs={abs:?}"); + assert!((abs.y - cb.y - 35.0).abs() < 1.0, "wrong absolute y: cb={cb:?} abs={abs:?}"); + assert_eq!( + layout.styles[&tree.get_element_by_id("abs").unwrap()].display, + obscura_render::Display::Block, + "positioned inline should be blockified" + ); + assert!((abs_end.x - 350.0).abs() < 1.0, "wrong right-inset x: {abs_end:?}"); + assert!((abs_end.y - 200.0).abs() < 1.0, "wrong bottom-inset y: {abs_end:?}"); + assert!((abs_percent.x - 260.0).abs() < 1.0, "wrong percent-inset x: {abs_percent:?}"); + assert!((abs_percent.y - 160.0).abs() < 1.0, "wrong percent-inset y: {abs_percent:?}"); + assert!((fixed.x - 520.0).abs() < 1.0, "fixed box did not use viewport x: {fixed:?}"); + assert!((fixed.y - 40.0).abs() < 1.0, "fixed box did not use viewport y: {fixed:?}"); +} + #[test] fn long_text_run_wraps_across_multiple_lines() { // A long single text node with no inline elements breaking it up must