From bbeceb97adfa2b1712a057208608d60f4379d79b Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:47:00 +0000 Subject: [PATCH] render: honor transform containing blocks Track every non-none transform as a containing-block trigger, including unsupported visual transforms. Keep separate nearest-ancestor maps so positioned boxes capture absolute descendants while only transforms capture fixed descendants. --- crates/obscura-render/src/dom.rs | 48 ++++++++++++++-------- crates/obscura-render/src/lib.rs | 4 ++ crates/obscura-render/src/style.rs | 8 +++- crates/obscura-render/tests/layout_test.rs | 42 +++++++++++++++++++ 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 6fb8e44..8edfdf1 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -1156,13 +1156,13 @@ fn compute_absolute_rects( /// 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. +/// parent. CSS instead uses the nearest positioned or transformed ancestor; +/// fixed boxes use the nearest transformed ancestor or the initial containing +/// block. Gecko represents that distinction by reparenting the positioned +/// 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 path is implemented. fn reparent_inset_positioned_nodes( tree: &DomTree, taffy_tree: &mut TaffyTree, @@ -1172,24 +1172,36 @@ fn reparent_inset_positioned_nodes( ) { 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(); + let mut nearest_abs_cb_for_children: HashMap = HashMap::new(); + let mut nearest_fixed_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()) + let inherited_abs_cb = parent + .and_then(|id| nearest_abs_cb_for_children.get(&id).copied()) + .unwrap_or(taffy_root); + let inherited_fixed_cb = parent + .and_then(|id| nearest_fixed_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) + // O(1) nearest-containing-block lookups. Positioned and transformed + // boxes capture absolute descendants; only transformed boxes capture + // fixed descendants. The full walk stays O(n). + let own_box = reverse.get(&dom_id).copied(); + let abs_child_cb = if style.position.is_some() || style.transform_establishes_containing_block { + own_box.unwrap_or(inherited_abs_cb) } else { - inherited_cb + inherited_abs_cb }; - nearest_cb_for_children.insert(dom_id, child_cb); + let fixed_child_cb = if style.transform_establishes_containing_block { + own_box.unwrap_or(inherited_fixed_cb) + } else { + inherited_fixed_cb + }; + nearest_abs_cb_for_children.insert(dom_id, abs_child_cb); + nearest_fixed_cb_for_children.insert(dom_id, fixed_child_cb); if !matches!(style.position, Some(taffy::Position::Absolute)) { continue; @@ -1201,9 +1213,9 @@ fn reparent_inset_positioned_nodes( } let Some(&child) = reverse.get(&dom_id) else { continue }; let target = if style.position_fixed { - taffy_root + inherited_fixed_cb } else { - inherited_cb + inherited_abs_cb }; let Some(current) = taffy_tree.parent(child) else { continue }; if current == target { diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 946cbd8..13d4a4d 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -415,6 +415,10 @@ pub struct LayoutStyle { /// off-screen skip-link out of view instead of painting it on-screen. Not /// inherited (transform is a non-inherited property). pub transform_translate: Option<(Dimension, Dimension)>, + /// Any computed transform other than `none` establishes containing blocks + /// for absolute and fixed descendants, even when its visual transform + /// function is not yet supported by the paint engine. + pub transform_establishes_containing_block: bool, /// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy). /// Parsed and stored so a value that mixes scale with translate still yields /// its translate part; scale is not yet folded into paint geometry (doing so diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 9633231..660318c 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -623,12 +623,18 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { if v.eq_ignore_ascii_case("none") { style.transform_translate = None; style.transform_scale = None; + style.transform_establishes_containing_block = false; return; } + let functions = transform_functions(v); + if functions.is_empty() { + return; + } + style.transform_establishes_containing_block = true; let zero = crate::Dimension::Px(0.0); let (mut tx, mut ty): (Option, Option) = (None, None); let (mut sx, mut sy): (Option, Option) = (None, None); - for (func, args) in transform_functions(v) { + for (func, args) in functions { let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); match func.to_ascii_lowercase().as_str() { // translate3d's z component is ignored (no perspective model); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 2c6a577..5e98730 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -161,6 +161,48 @@ fn inset_absolute_uses_nearest_positioned_ancestor() { assert!((fixed.y - 40.0).abs() < 1.0, "fixed box did not use viewport y: {fixed:?}"); } +#[test] +fn transforms_establish_absolute_and_fixed_containing_blocks() { + let html = r##" + +
+
+
+ +
+
+
+
+
+
+
+
+ + "##; + let tree = parse_html(html); + let layout = layout_dom(&tree, (900.0, 700.0)); + let transformer_id = tree.get_element_by_id("transformer").unwrap(); + let transformer = layout.rects[&transformer_id]; + let abs = layout.rects[&tree.get_element_by_id("abs").unwrap()]; + let fixed_transformed = layout.rects[&tree.get_element_by_id("fixed-transformed").unwrap()]; + let identity_transform = layout.rects[&tree.get_element_by_id("identity-transform").unwrap()]; + let identity_abs = layout.rects[&tree.get_element_by_id("identity-abs").unwrap()]; + let fixed_viewport = layout.rects[&tree.get_element_by_id("fixed-viewport").unwrap()]; + + // Layout rects precede paint transforms. Insets use the transformer's + // padding box; the shared translate is recorded separately for the whole + // DOM subtree. + assert!((abs.x - transformer.x - 25.0).abs() < 1.0, "wrong transformed abs x: transformer={transformer:?} abs={abs:?}"); + assert!((abs.y - transformer.y - 30.0).abs() < 1.0, "wrong transformed abs y: transformer={transformer:?} abs={abs:?}"); + assert!((fixed_transformed.x - transformer.x - 155.0).abs() < 1.0, "wrong transformed fixed x: {fixed_transformed:?}"); + assert!((fixed_transformed.y - transformer.y - 105.0).abs() < 1.0, "wrong transformed fixed y: {fixed_transformed:?}"); + assert_eq!(layout.translates[&transformer_id], (30.0, 20.0)); + assert!((identity_abs.x - identity_transform.x - 70.0).abs() < 1.0, "unsupported transform did not establish abs x: {identity_abs:?}"); + assert!((identity_abs.y - identity_transform.y - 50.0).abs() < 1.0, "unsupported transform did not establish abs y: {identity_abs:?}"); + assert!((fixed_viewport.x - 700.0).abs() < 1.0, "positioned ancestor captured fixed x: {fixed_viewport:?}"); + assert!((fixed_viewport.y - 40.0).abs() < 1.0, "positioned ancestor captured fixed y: {fixed_viewport:?}"); +} + #[test] fn long_text_run_wraps_across_multiple_lines() { // A long single text node with no inline elements breaking it up must