From 90c64b2c31184b24285a4d7ab31fee90674c88eb Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:15:28 +0000 Subject: [PATCH] Support safe column subgrid propagation --- crates/obscura-render/src/dom.rs | 396 +++++++++++++++++++++ crates/obscura-render/src/lib.rs | 4 + crates/obscura-render/src/style.rs | 20 +- crates/obscura-render/tests/layout_test.rs | 147 ++++++++ 4 files changed, 566 insertions(+), 1 deletion(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 7cd899b..5bd0c9d 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -2378,6 +2378,30 @@ pub(crate) fn layout_dom_with_web_fonts( &mut measure, ); } + if apply_full_span_column_subgrids( + tree, + &mut taffy_tree, + &id_map, + &styles, + |tree, node| { + tree.compute_layout_with_measure( + node, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + &mut measure, + ) + .ok()?; + tree.layout(node).ok().map(|layout| layout.size.width) + }, + ) { + let _ = taffy_tree.compute_layout_with_measure( + taffy_root, + available, + &mut measure, + ); + } if apply_table_cell_block_alignment( tree, &mut taffy_tree, @@ -2421,6 +2445,25 @@ pub(crate) fn layout_dom_with_web_fonts( ) { let _ = taffy_tree.compute_layout(taffy_root, available); } + if apply_full_span_column_subgrids( + tree, + &mut taffy_tree, + &id_map, + &styles, + |tree, node| { + tree.compute_layout( + node, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + ) + .ok()?; + tree.layout(node).ok().map(|layout| layout.size.width) + }, + ) { + let _ = taffy_tree.compute_layout(taffy_root, available); + } if apply_table_cell_block_alignment( tree, &mut taffy_tree, @@ -2715,6 +2758,359 @@ fn resolve_grid_areas( } } +#[derive(Clone, Copy)] +struct ColumnSubgridWrapper { + node: taffy::NodeId, + gap: f32, + start_mbp: f32, + end_mbp: f32, +} + +#[derive(Clone, Copy)] +struct ColumnSubgridLeaf { + node: taffy::NodeId, + dom: NodeId, + column: usize, + gap: f32, + start_mbp: f32, + end_mbp: f32, +} + +struct ColumnSubgridPlan { + parent: taffy::NodeId, + track_count: usize, + parent_gap: f32, + wrappers: Vec, + leaves: Vec, +} + +fn is_full_span_column_subgrid(style: &crate::LayoutStyle) -> bool { + if style.display != crate::Display::Grid + || !style.grid_template_columns_subgrid + || style.overflow_hidden + || style.position == Some(taffy::Position::Absolute) + || style.width != crate::Dimension::Auto + || style.justify_self.is_some_and(|value| value != taffy::AlignSelf::STRETCH) + || style.margin_auto[1] + || style.margin_auto[3] + { + return false; + } + let Some(line) = &style.grid_column else { + return false; + }; + matches!( + (&line.start, &line.end), + (taffy::GridPlacement::Line(start), taffy::GridPlacement::Line(end)) + if start.as_i16() == 1 && end.as_i16() == -1 + ) +} + +/// Collect the deliberately bounded Grid Level 2 subset used by broad +/// "aligned rows" components: a full-span column subgrid, optionally nested +/// through more full-span column subgrids, whose final children auto-place one +/// per inherited column. Partial spans, explicit leaf placement, independent +/// formatting contexts, and authored inline sizing are left on the existing +/// fallback rather than being represented inaccurately. +fn collect_column_subgrid_descendants( + tree: &DomTree, + dom: NodeId, + track_count: usize, + taffy_by_dom: &HashMap, + styles: &HashMap, + start_mbp: f32, + end_mbp: f32, + depth: usize, + wrappers: &mut Vec, + leaves: &mut Vec, +) -> bool { + if depth > 8 { + return false; + } + let Some(style) = styles.get(&dom) else { + return false; + }; + let Some(&node) = taffy_by_dom.get(&dom) else { + return false; + }; + let start_mbp = start_mbp + style.margin.left + style.border.left + style.padding.left; + let end_mbp = end_mbp + style.margin.right + style.border.right + style.padding.right; + let gap = style.column_gap.unwrap_or(0.0); + wrappers.push(ColumnSubgridWrapper { node, gap, start_mbp, end_mbp }); + + let children: Vec = tree + .children(dom) + .into_iter() + .filter(|child| { + styles + .get(child) + .map(|style| style.display != crate::Display::None) + .unwrap_or(false) + }) + .collect(); + if children.is_empty() { + return false; + } + let nested = children + .iter() + .filter(|child| styles.get(child).is_some_and(is_full_span_column_subgrid)) + .count(); + if nested > 0 { + if nested != children.len() { + return false; + } + return children.into_iter().all(|child| { + collect_column_subgrid_descendants( + tree, + child, + track_count, + taffy_by_dom, + styles, + start_mbp, + end_mbp, + depth + 1, + wrappers, + leaves, + ) + }); + } + + let flow = style.grid_auto_flow.unwrap_or(taffy::GridAutoFlow::Row); + if !matches!(flow, taffy::GridAutoFlow::Row | taffy::GridAutoFlow::RowDense) { + return false; + } + for (index, child) in children.into_iter().enumerate() { + let Some(child_style) = styles.get(&child) else { + return false; + }; + // This first subset intentionally excludes spanning and explicitly + // placed items. It is the common data-row/card-row pattern and keeps + // each descendant contribution attributable to one ancestor track. + if child_style.grid_column.is_some() + || child_style.position == Some(taffy::Position::Absolute) + || child_style.margin_auto[1] + || child_style.margin_auto[3] + { + return false; + } + let Some(&node) = taffy_by_dom.get(&child) else { + return false; + }; + leaves.push(ColumnSubgridLeaf { + node, + dom: child, + column: index % track_count, + gap, + start_mbp, + end_mbp, + }); + } + true +} + +fn fixed_grid_tracks(widths: &[f32]) -> Vec> { + widths + .iter() + .map(|width| { + taffy::GridTemplateComponent::Single(taffy::MinMax { + min: taffy::MinTrackSizingFunction::length((*width).max(0.0)), + max: taffy::MaxTrackSizingFunction::length((*width).max(0.0)), + }) + }) + .collect() +} + +/// Resolve a safe full-span column-subgrid subset in two passes. +/// +/// Gecko first collects subgrid descendants into the nearest non-subgridded +/// ancestor's track sizing, then copies that ancestor's *used* track sizes +/// down the chain. Its descendant contributions add accumulated edge +/// margin/border/padding and center a custom subgrid gap over the ancestor +/// gap. Taffy has no subgrid primitive, so we reproduce those same operations +/// only for definite, all-auto, single-span rows. Once every max-content +/// growth limit fits, `justify-content:normal` stretches all auto tracks by an +/// equal share; narrower/cyclic cases decline this fast path. +/// +/// The current style model does not expose orthogonal writing modes and emits +/// Taffy's default LTR direction for every grid; this physical-column reduction +/// therefore cannot accidentally enter an RTL/orthogonal layout path that the +/// surrounding renderer does not implement yet. Percentage-width parents are +/// accepted only after the preliminary layout produced finite, explicit used +/// track sizes; that resolved track sum is the definite basis for pass two. +fn apply_full_span_column_subgrids( + tree: &DomTree, + taffy_tree: &mut TaffyTree, + id_map: &HashMap, + styles: &HashMap, + mut measure_max_content: F, +) -> bool +where + F: FnMut(&mut TaffyTree, taffy::NodeId) -> Option, +{ + let taffy_by_dom: HashMap = + id_map.iter().map(|(taffy, dom)| (*dom, *taffy)).collect(); + let mut plans = Vec::new(); + + for (&dom, style) in styles { + if style.display != crate::Display::Grid + || style.grid_template_columns_subgrid + || !matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_)) + || style.justify_content.is_some_and(|value| value != taffy::JustifyContent::STRETCH) + { + continue; + } + let all_auto = !style.grid_template_columns.is_empty() + && style.grid_template_columns.iter().all(|track| { + matches!( + track, + taffy::GridTemplateComponent::Single(size) + if size.min.is_auto() && size.max.is_auto() + ) + }); + if !all_auto || style.grid_template_columns.len() > 32 { + continue; + } + let track_count = style.grid_template_columns.len(); + let direct: Vec = tree + .children(dom) + .into_iter() + .filter(|child| styles.get(child).is_some_and(|style| style.display != crate::Display::None)) + .collect(); + if direct.is_empty() + || !direct + .iter() + .all(|child| styles.get(child).is_some_and(is_full_span_column_subgrid)) + { + continue; + } + let Some(&parent) = taffy_by_dom.get(&dom) else { + continue; + }; + let mut wrappers = Vec::new(); + let mut leaves = Vec::new(); + if !direct.into_iter().all(|child| { + collect_column_subgrid_descendants( + tree, + child, + track_count, + &taffy_by_dom, + styles, + 0.0, + 0.0, + 0, + &mut wrappers, + &mut leaves, + ) + }) || leaves.is_empty() + { + continue; + } + plans.push(ColumnSubgridPlan { + parent, + track_count, + parent_gap: style.column_gap.unwrap_or(0.0), + wrappers, + leaves, + }); + } + + let mut changed = false; + for plan in plans { + let target_track_sum = match taffy_tree.detailed_layout_info(plan.parent) { + taffy::tree::DetailedLayoutInfo::Grid(info) + if info.columns.negative_implicit_tracks == 0 + && info.columns.positive_implicit_tracks == 0 + && info.columns.explicit_tracks as usize == plan.track_count => + { + info.columns.sizes.iter().sum::() + } + _ => continue, + }; + if !target_track_sum.is_finite() || target_track_sum <= 0.0 { + continue; + } + let mut max_content = vec![0.0f32; plan.track_count]; + let mut valid = true; + for leaf in &plan.leaves { + let Some(mut contribution) = measure_max_content(taffy_tree, leaf.node) else { + valid = false; + break; + }; + let Some(leaf_style) = styles.get(&leaf.dom) else { + valid = false; + break; + }; + contribution += leaf_style.margin.left + leaf_style.margin.right; + if plan.track_count > 1 { + let gap_delta = leaf.gap - plan.parent_gap; + contribution += if leaf.column == 0 || leaf.column + 1 == plan.track_count { + gap_delta / 2.0 + } else { + gap_delta + }; + } + if leaf.column == 0 { + contribution += leaf.start_mbp; + } + if leaf.column + 1 == plan.track_count { + contribution += leaf.end_mbp; + } + max_content[leaf.column] = max_content[leaf.column].max(contribution.max(0.0)); + } + let max_sum: f32 = max_content.iter().sum(); + if !valid || max_sum > target_track_sum + 0.01 { + continue; + } + let stretch = (target_track_sum - max_sum) / plan.track_count as f32; + let used: Vec = max_content.iter().map(|width| width + stretch).collect(); + if used.iter().any(|width| !width.is_finite() || *width < 0.0) { + continue; + } + + // Validate the entire copied chain before mutating anything. A large + // edge MBP or gap delta can exhaust an outer track; declining the plan + // atomically is safer than leaving only the ancestor frozen. + let mut copied_wrappers = Vec::with_capacity(plan.wrappers.len()); + for wrapper in &plan.wrappers { + let mut copied = used.clone(); + if plan.track_count > 1 { + let root_half = plan.parent_gap / 2.0; + let child_half = wrapper.gap / 2.0; + for (index, width) in copied.iter_mut().enumerate() { + *width += if index == 0 || index + 1 == plan.track_count { + root_half - child_half + } else { + plan.parent_gap - wrapper.gap + }; + } + } + copied[0] -= wrapper.start_mbp; + copied[plan.track_count - 1] -= wrapper.end_mbp; + if copied.iter().any(|width| !width.is_finite() || *width < 0.0) + || taffy_tree.style(wrapper.node).is_err() + { + valid = false; + break; + } + copied_wrappers.push((wrapper.node, copied)); + } + if !valid || taffy_tree.style(plan.parent).is_err() { + continue; + } + + let mut parent_style = taffy_tree.style(plan.parent).unwrap().clone(); + parent_style.grid_template_columns = fixed_grid_tracks(&used); + let _ = taffy_tree.set_style(plan.parent, parent_style); + for (node, copied) in copied_wrappers { + let mut style = taffy_tree.style(node).unwrap().clone(); + style.grid_template_columns = fixed_grid_tracks(&copied); + let _ = taffy_tree.set_style(node, style); + } + changed = true; + } + changed +} + /// Resolve a raw `grid-column`/`grid-row` value that names grid lines into a /// numeric `taffy::Line`, using `map` (line-name -> 1-based line number). Handles /// `a / b` (each side a name, integer, or `span n`) and the single-ident diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 8906f88..5d870e9 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -365,6 +365,10 @@ pub struct LayoutStyle { // resolved to line placements on children in a later pass. pub grid_template_columns: Vec>, pub grid_template_rows: Vec>, + /// `grid-template-columns: subgrid`. Taffy has no native subgrid track + /// component, so `dom` resolves the safe full-span column subset after + /// measuring the non-subgridded ancestor's intrinsic track contributions. + pub grid_template_columns_subgrid: bool, pub grid_auto_flow: Option, pub grid_areas: Option>>, pub grid_area_name: Option, diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index f8dc18b..8557bc7 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -993,6 +993,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "grid-template-columns" => { let (tracks, names) = parse_track_list_named(value); + style.grid_template_columns_subgrid = is_subgrid_track_list(value); style.grid_template_columns = tracks; style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names)); } @@ -1749,10 +1750,17 @@ pub(crate) fn parse_track_list_named( Vec>, Vec<(String, i16)>, ) { + let tokens = tokenize_tracks(value); let mut tracks = Vec::new(); let mut names = Vec::new(); let mut line: i16 = 1; - for tok in tokenize_tracks(value) { + // A subgridded axis owns line names but no sizing functions. Do not turn + // the keyword into the generic unknown-token => auto-track fallback. + let is_subgrid = tokens + .first() + .map(|token| token.eq_ignore_ascii_case("subgrid")) + .unwrap_or(false); + for tok in tokens.into_iter().skip(usize::from(is_subgrid)) { expand_track_token(&tok, &mut tracks, &mut names, &mut line); } (tracks, names) @@ -1945,6 +1953,7 @@ fn parse_grid_template(style: &mut LayoutStyle, value: &str) { } if let Some(cols) = cols_part { let (tracks, names) = parse_track_list_named(cols); + style.grid_template_columns_subgrid = is_subgrid_track_list(cols); style.grid_template_columns = tracks; style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names)); } @@ -1963,6 +1972,7 @@ fn parse_grid_shorthand(style: &mut LayoutStyle, value: &str) { if rows.to_ascii_lowercase().contains("auto-flow") { style.grid_template_rows.clear(); let (tracks, names) = parse_track_list_named(columns); + style.grid_template_columns_subgrid = is_subgrid_track_list(columns); style.grid_template_columns = tracks; style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names)); style.grid_auto_flow = Some(if rows.to_ascii_lowercase().contains("dense") { @@ -1972,6 +1982,7 @@ fn parse_grid_shorthand(style: &mut LayoutStyle, value: &str) { }); } else if columns.to_ascii_lowercase().contains("auto-flow") { style.grid_template_columns.clear(); + style.grid_template_columns_subgrid = false; let (tracks, names) = parse_track_list_named(rows); style.grid_template_rows = tracks; style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names)); @@ -1985,6 +1996,13 @@ fn parse_grid_shorthand(style: &mut LayoutStyle, value: &str) { } } +fn is_subgrid_track_list(value: &str) -> bool { + tokenize_tracks(value) + .first() + .map(|token| token.eq_ignore_ascii_case("subgrid")) + .unwrap_or(false) +} + fn parse_grid_auto_flow(value: &str) -> Option { let lower = value.trim().to_ascii_lowercase(); let dense = lower.split_whitespace().any(|token| token == "dense"); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 06185ea..266f96d 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -1512,6 +1512,153 @@ fn auto_grid_track_freezes_at_growth_limit_during_distribution() { ); } +/// Chromium 150 oracle for the full-span nested column-subgrid pattern used by +/// Mozilla's springboard/data rows. The 20px child gap is centered on the +/// ancestor's zero-width grid lines, so descendant contributions inflate the +/// ancestor tracks by [10, 20, 10]. The remaining 240px then stretches the +/// three auto tracks equally: 130px, 180px, 190px. +#[test] +fn nested_full_span_column_subgrid_shares_ancestor_tracks() { + let tree = parse_html( + r#" + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let parent = rect("parent"); + let row = rect("row-one"); + let inner = rect("inner-one"); + let a = rect("a"); + let b = rect("b"); + let c = rect("c"); + + assert!( + (parent.width - 500.0).abs() < 0.01 + && (parent.height - 40.0).abs() < 0.01 + && (row.width - 500.0).abs() < 0.01 + && (row.height - 20.0).abs() < 0.01 + && (inner.width - 500.0).abs() < 0.01 + && (inner.height - 20.0).abs() < 0.01, + "nested subgrids must share one row of ancestor tracks: \ + parent={parent:?} row={row:?} inner={inner:?}" + ); + assert!( + (a.x - 0.0).abs() < 0.01 + && (a.width - 40.0).abs() < 0.01 + && (b.x - 140.0).abs() < 0.01 + && (b.width - 80.0).abs() < 0.01 + && (c.x - 320.0).abs() < 0.01 + && (c.width - 100.0).abs() < 0.01, + "Chrome column positions: a={a:?} b={b:?} c={c:?}" + ); + + let repeated = layout_dom(&tree, (800.0, 600.0)); + for id in ["parent", "row-one", "inner-one", "a", "b", "c"] { + let node = tree.get_element_by_id(id).unwrap(); + assert_eq!( + layout.rects[&node], repeated.rects[&node], + "the two-pass reduction must be stable across repeated layouts: {id}" + ); + } +} + +/// Cases outside the bounded reduction must retain the existing fallback, +/// rather than freezing an invented set of ancestor tracks. The narrow parent +/// cannot fit every max-content growth limit, and the second row only spans a +/// subset of its parent columns. +#[test] +fn unsafe_column_subgrid_cases_decline_the_fast_path() { + let tree = parse_html( + r#" + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let narrow = rect("narrow"); + let narrow_b = rect("narrow-b"); + let partial = rect("partial"); + let partial_b = rect("partial-b"); + + assert!( + (narrow.height - 60.0).abs() < 0.01 && (narrow_b.x - narrow.x).abs() < 0.01, + "max-content overflow must retain the stacked fallback: {narrow:?} {narrow_b:?}" + ); + assert!( + (partial.height - 60.0).abs() < 0.01 && (partial_b.x - partial.x).abs() < 0.01, + "partial-span subgrid must retain the stacked fallback: {partial:?} {partial_b:?}" + ); +} + #[test] fn empty_block_before_participates_in_normal_flow_geometry() { let tree = parse_html(