diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index bbb223f..51e19a8 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -46,6 +46,104 @@ pub struct InvalidationDependency { pub reaches: InvalidationReaches, } +/// A cheap positive key from the compound which anchors one `:has()`. +/// +/// This is only an early rejection filter. Compounds without a direct key +/// remain unkeyed rather than borrowing a key from `:is()`/`:not()`, whose +/// boolean structure cannot be represented by one key without false negatives. +#[derive(Clone, Debug, PartialEq, Eq)] +enum RelationalSelectorKey { + Id(String), + Class(String), + Attribute(String), + LocalName(String), +} + +/// Invalidation metadata for one `:has()` occurrence. +/// +/// Gecko models the selector inside `:has()` as an upward dependency chain +/// (parent/ancestors/previous siblings), then resumes the ordinary selector +/// path outside the anchor. Obscura stores the smaller information needed by +/// its whole-subtree cascade: an optional anchor key, the outward reach, and +/// whether a key-independent child-list side effect can change the match. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RelationalInvalidation { + pub rule_order: usize, + anchor_key: Option, + relative_keys: Vec, + pub anchor_reaches: InvalidationReaches, + pub unkeyed_subject: bool, + pub sibling_side_effect: bool, + pub structural_side_effect: bool, + pub text_side_effect: bool, + pub unrepresentable_outer_path: bool, +} + +impl RelationalInvalidation { + pub(crate) fn anchor_may_match(&self, tree: &DomTree, node: NodeId) -> bool { + let Some(dom_node) = tree.get_node(node) else { + return false; + }; + if dom_node.as_element().is_none() { + return false; + } + let quirks = tree.is_quirks(); + match self.anchor_key.as_ref() { + None => true, + Some(RelationalSelectorKey::Id(expected)) => dom_node + .get_attribute("id") + .is_some_and(|actual| { + actual == expected || (quirks && actual.eq_ignore_ascii_case(expected)) + }), + Some(RelationalSelectorKey::Class(expected)) => dom_node + .get_attribute("class") + .is_some_and(|classes| { + classes.split_whitespace().any(|actual| { + actual == expected || (quirks && actual.eq_ignore_ascii_case(expected)) + }) + }), + Some(RelationalSelectorKey::Attribute(expected)) => { + dom_node.get_attribute(expected).is_some() + } + Some(RelationalSelectorKey::LocalName(expected)) => dom_node + .as_element() + .is_some_and(|element| element.local.as_ref().eq_ignore_ascii_case(expected)), + } + } + + pub(crate) fn relative_path_may_match(&self, tree: &DomTree, node: NodeId) -> bool { + let Some(dom_node) = tree.get_node(node) else { + return false; + }; + let Some(element) = dom_node.as_element() else { + return false; + }; + let quirks = tree.is_quirks(); + self.relative_keys.iter().any(|key| match key { + RelationalSelectorKey::Id(expected) => dom_node + .get_attribute("id") + .is_some_and(|actual| { + actual == expected || (quirks && actual.eq_ignore_ascii_case(expected)) + }), + RelationalSelectorKey::Class(expected) => dom_node + .get_attribute("class") + .is_some_and(|classes| { + classes.split_whitespace().any(|actual| { + actual == expected || (quirks && actual.eq_ignore_ascii_case(expected)) + }) + }), + RelationalSelectorKey::Attribute(expected) => dom_node.attrs().is_some_and(|attrs| { + attrs + .iter() + .any(|attribute| attribute.name.local.as_ref().eq_ignore_ascii_case(expected)) + }), + RelationalSelectorKey::LocalName(expected) => { + element.local.as_ref().eq_ignore_ascii_case(expected) + } + }) + } +} + /// Selector dependencies retained alongside the compiled stylesheet. /// /// This follows Gecko's conservative invalidation-map shape: live mutations @@ -62,6 +160,7 @@ pub struct InvalidationMap { conservative_rule_orders: Vec, relational_rule_orders: Vec, unkeyed_relational_rule_orders: Vec, + relational_invalidations: Vec, } impl InvalidationMap { @@ -110,6 +209,10 @@ impl InvalidationMap { !self.unkeyed_relational_rule_orders.is_empty() } + pub(crate) fn relational_invalidations(&self) -> &[RelationalInvalidation] { + &self.relational_invalidations + } + pub fn dependency_count(&self) -> usize { self.ids .values() @@ -192,6 +295,12 @@ impl InvalidationMap { self.unkeyed_relational_rule_orders.push(rule_order); } } + + fn push_relational_invalidation(&mut self, invalidation: RelationalInvalidation) { + if !self.relational_invalidations.contains(&invalidation) { + self.relational_invalidations.push(invalidation); + } + } } fn compose_invalidation_reach( @@ -567,6 +676,259 @@ fn relative_selector_subject_has_key(selector: &str) -> bool { false } +/// Pick one positive key which every match of this exact anchor compound must +/// have. Functional pseudos are skipped as opaque boolean expressions; using +/// a key from one `:is()` arm as a mandatory filter would be unsound. +fn relational_anchor_key(compound: &str) -> Option { + if let Some(local_name) = compound_local_name(compound) { + return Some(RelationalSelectorKey::LocalName(local_name)); + } + let chars = compound.chars().collect::>(); + let mut index = 0usize; + while index < chars.len() { + match chars[index] { + '#' => { + let (id, next) = consume_css_identifier(&chars, index + 1); + if !id.is_empty() { + return Some(RelationalSelectorKey::Id(id)); + } + index = next.max(index + 1); + } + '.' => { + let (class, next) = consume_css_identifier(&chars, index + 1); + if !class.is_empty() { + return Some(RelationalSelectorKey::Class(class)); + } + index = next.max(index + 1); + } + '[' => { + let close = matching_delimiter(&chars, index, '[', ']')?; + let mut name_index = index + 1; + while chars.get(name_index).is_some_and(|ch| ch.is_whitespace()) { + name_index += 1; + } + // `Node::get_attribute` is intentionally the unqualified HTML + // lookup. A namespace-qualified selector cannot use that as a + // mandatory anchor filter without risking a false rejection. + if chars.get(name_index) == Some(&'*') || chars.get(name_index) == Some(&'|') { + index = close + 1; + continue; + } + let (first, first_end) = consume_css_identifier(&chars, name_index); + if chars.get(first_end) == Some(&'|') { + index = close + 1; + continue; + } + let (attribute, end) = (first, first_end); + if !attribute.is_empty() && end <= close { + return Some(RelationalSelectorKey::Attribute( + attribute.to_ascii_lowercase(), + )); + } + index = close + 1; + } + ':' => { + let pseudo_start = index + usize::from(chars.get(index + 1) == Some(&':')) + 1; + let (_, next) = consume_css_identifier(&chars, pseudo_start); + if chars.get(next) == Some(&'(') { + let close = matching_delimiter(&chars, next, '(', ')')?; + index = close + 1; + } else { + index = next.max(index + 1); + } + } + '\\' => index = (index + 2).min(chars.len()), + _ => index += 1, + } + } + None +} + +fn push_relational_selector_key( + keys: &mut Vec, + key: RelationalSelectorKey, +) { + if !keys.contains(&key) { + keys.push(key); + } +} + +/// Collect cheap positive keys anywhere along a relative-selector path. A +/// child-list insertion which contains none of these keys cannot make the path +/// start matching. This is an early rejection only: each key is treated as an +/// alternative trigger, so compounds requiring several keys remain sound. +fn collect_relational_selector_keys( + selector: &str, + keys: &mut Vec, +) { + let selector = selector.trim(); + let selector = selector + .chars() + .next() + .filter(|character| matches!(character, '>' | '+' | '~')) + .map(|character| selector[character.len_utf8()..].trim_start()) + .unwrap_or(selector); + let (compounds, _) = invalidation_compounds(selector); + for (compound, _) in compounds { + collect_relational_compound_keys(&compound, keys); + } +} + +fn collect_relational_compound_keys( + compound: &str, + keys: &mut Vec, +) { + if let Some(local_name) = compound_local_name(compound) { + push_relational_selector_key(keys, RelationalSelectorKey::LocalName(local_name)); + } + let chars = compound.chars().collect::>(); + let mut index = 0usize; + while index < chars.len() { + match chars[index] { + '#' => { + let (id, next) = consume_css_identifier(&chars, index + 1); + if !id.is_empty() { + push_relational_selector_key(keys, RelationalSelectorKey::Id(id)); + } + index = next.max(index + 1); + } + '.' => { + let (class, next) = consume_css_identifier(&chars, index + 1); + if !class.is_empty() { + push_relational_selector_key(keys, RelationalSelectorKey::Class(class)); + } + index = next.max(index + 1); + } + '[' => { + let Some(close) = matching_delimiter(&chars, index, '[', ']') else { + return; + }; + let mut name_index = index + 1; + while chars.get(name_index).is_some_and(|ch| ch.is_whitespace()) { + name_index += 1; + } + let (first, first_end) = if chars.get(name_index) == Some(&'*') { + (String::new(), name_index + 1) + } else { + consume_css_identifier(&chars, name_index) + }; + let (attribute, end) = if chars.get(first_end) == Some(&'|') { + consume_css_identifier(&chars, first_end + 1) + } else { + (first, first_end) + }; + if !attribute.is_empty() && end <= close { + push_relational_selector_key( + keys, + RelationalSelectorKey::Attribute(attribute.to_ascii_lowercase()), + ); + } + index = close + 1; + } + ':' => { + let pseudo_start = index + usize::from(chars.get(index + 1) == Some(&':')) + 1; + let (name, next) = consume_css_identifier(&chars, pseudo_start); + if chars.get(next) != Some(&'(') { + index = next.max(index + 1); + continue; + } + let Some(close) = matching_delimiter(&chars, next, '(', ')') else { + return; + }; + let arguments = chars[next + 1..close].iter().collect::(); + match name.to_ascii_lowercase().as_str() { + "is" | "where" | "not" | "has" => { + for alternative in split_selector_list(&arguments) { + collect_relational_selector_keys(alternative.trim(), keys); + } + } + "nth-child" | "nth-last-child" | "nth-of-type" | "nth-last-of-type" => { + if let Some(of_selector) = nth_of_selector(&arguments) { + for alternative in split_selector_list(of_selector) { + collect_relational_selector_keys(alternative.trim(), keys); + } + } + } + _ => {} + } + index = close + 1; + } + '\\' => index = (index + 2).min(chars.len()), + _ => index += 1, + } + } +} + +fn selector_contains_pseudo(selector: &str, names: &[&str]) -> bool { + let chars = selector.chars().collect::>(); + let mut index = 0usize; + let mut quote = None; + let mut bracket_depth = 0usize; + while index < chars.len() { + let ch = chars[index]; + if ch == '\\' { + index = (index + 2).min(chars.len()); + continue; + } + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } + index += 1; + continue; + } + match ch { + '\'' | '"' => quote = Some(ch), + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + ':' if bracket_depth == 0 && chars.get(index + 1) != Some(&':') => { + let (name, next) = consume_css_identifier(&chars, index + 1); + if names + .iter() + .any(|expected| name.eq_ignore_ascii_case(expected)) + { + return true; + } + index = next.max(index + 1); + continue; + } + _ => {} + } + index += 1; + } + false +} + +fn selector_contains_adjacent_combinator(selector: &str) -> bool { + let chars = selector.chars().collect::>(); + let mut index = 0usize; + let mut quote = None; + let mut bracket_depth = 0usize; + while index < chars.len() { + let ch = chars[index]; + if ch == '\\' { + index = (index + 2).min(chars.len()); + continue; + } + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } + index += 1; + continue; + } + match ch { + '\'' | '"' => quote = Some(ch), + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + '+' if bracket_depth == 0 => return true, + _ => {} + } + index += 1; + } + false +} + fn note_compound_dependencies( map: &mut InvalidationMap, compound: &str, @@ -688,10 +1050,55 @@ fn note_compound_dependencies( ); map.mark_conservative(rule_order); let alternatives = split_selector_list(&arguments); + let mut relative_keys = Vec::new(); + for alternative in &alternatives { + collect_relational_selector_keys( + alternative.trim(), + &mut relative_keys, + ); + } let unkeyed = alternatives.iter().any(|alternative| { !relative_selector_subject_has_key(alternative.trim()) }); map.mark_relational(rule_order, unkeyed); + let sibling_side_effect = alternatives + .iter() + .any(|alternative| { + selector_contains_adjacent_combinator(alternative.trim()) + }); + let structural_side_effect = alternatives.iter().any(|alternative| { + selector_contains_pseudo( + alternative.trim(), + &[ + "empty", + "first-child", + "last-child", + "only-child", + "first-of-type", + "last-of-type", + "only-of-type", + "nth-child", + "nth-last-child", + "nth-of-type", + "nth-last-of-type", + ], + ) + }); + let text_side_effect = alternatives.iter().any(|alternative| { + selector_contains_pseudo(alternative.trim(), &["empty"]) + }); + map.push_relational_invalidation(RelationalInvalidation { + rule_order, + anchor_key: relational_anchor_key(compound), + relative_keys, + anchor_reaches: reaches, + unkeyed_subject: unkeyed, + sibling_side_effect, + structural_side_effect, + text_side_effect, + unrepresentable_outer_path: reaches + .contains(InvalidationReaches::CONSERVATIVE), + }); for alternative in alternatives { note_selector_dependencies( map, @@ -5972,6 +6379,59 @@ mod tests { } } + #[test] + fn relational_invalidation_records_anchor_reach_and_tree_side_effects() { + let map = test_invalidation_map( + r#" + .host:has(.signal) .out { color:red } + .row:has(+ :is(.notice,.warning)) { color:red } + .card:has(.item:first-child) { color:red } + .shell:has(.label:empty) { color:red } + .anchor:has(.signal) ~ .panel .leaf { color:red } + "#, + ); + let entries = map.relational_invalidations(); + assert_eq!(entries.len(), 5); + assert!(entries[0] + .anchor_reaches + .contains(InvalidationReaches::DESCENDANTS)); + assert!(!entries[0].unkeyed_subject); + assert!(entries[1].sibling_side_effect); + assert!(!entries[1].unkeyed_subject); + assert!(entries[2].structural_side_effect); + assert!(!entries[2].text_side_effect); + assert!(entries[3].structural_side_effect); + assert!(entries[3].text_side_effect); + assert!(entries[4].unrepresentable_outer_path); + + let tree = obscura_dom::parse_html( + "
", + ); + assert!(entries[0].anchor_may_match( + &tree, + tree.get_element_by_id("host").unwrap(), + )); + assert!(!entries[0].anchor_may_match( + &tree, + tree.get_element_by_id("other").unwrap(), + )); + } + + #[test] + fn relational_anchor_filter_never_unqualifies_namespaced_attributes() { + for compound in [ + "[xlink|href]:has(.signal)", + "[*|href]:has(.signal)", + "[|href]:has(.signal)", + ] { + assert_eq!(relational_anchor_key(compound), None, "{compound}"); + } + assert_eq!( + relational_anchor_key("[xlink|href].host:has(.signal)"), + Some(RelationalSelectorKey::Class("host".into())), + ); + } + #[test] fn invalidation_map_includes_generated_content_host_dependencies() { let map = test_invalidation_map( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 05947d8..505553c 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -2460,68 +2460,140 @@ fn node_is_style_text(tree: &DomTree, node: NodeId, parent: Option) -> b }) } -fn dependencies_include_relational_rule( - map: &crate::css::InvalidationMap, - dependencies: &[crate::css::InvalidationDependency], -) -> bool { - dependencies - .iter() - .any(|dependency| { - map.is_relational_rule(dependency.rule_order) - && dependency - .reaches - .contains(crate::css::InvalidationReaches::CONSERVATIVE) - }) -} - -fn subtree_may_affect_relational_selector( +fn subtree_may_match_relational_path( tree: &DomTree, - map: &crate::css::InvalidationMap, + invalidation: &crate::css::RelationalInvalidation, root: NodeId, ) -> bool { - if map.has_unkeyed_relational_rules() { - return true; - } std::iter::once(root) .chain(tree.descendants(root)) - .any(|id| { - let Some(node) = tree.get_node(id) else { - return false; + .any(|id| invalidation.relative_path_may_match(tree, id)) +} + +/// Candidate anchors after an insertion. Relative selectors search from the +/// changed subtree toward ancestors and earlier siblings; only the boundary +/// can connect the already-fresh inserted subtree to retained elements. +fn add_inserted_relational_anchor_candidates( + tree: &DomTree, + node: NodeId, + candidates: &mut HashSet, +) { + let mut current = Some(node); + while let Some(id) = current { + let mut sibling = tree.get_node(id).and_then(|node| node.prev_sibling); + while let Some(previous) = sibling { + candidates.insert(previous); + sibling = tree.get_node(previous).and_then(|node| node.prev_sibling); + } + current = tree.get_node(id).and_then(|node| node.parent); + if let Some(parent) = current { + candidates.insert(parent); + } + } +} + +/// Removal has already destroyed the old sibling links. As Gecko does for a +/// removal side effect, inspect every sibling at each old ancestor boundary; +/// this includes the old previous/next neighbors without retaining a DOM +/// snapshot in every mutation record. +fn add_removed_relational_anchor_candidates( + tree: &DomTree, + old_parent: NodeId, + candidates: &mut HashSet, +) { + let mut current = Some(old_parent); + while let Some(id) = current { + candidates.insert(id); + candidates.extend(tree.children(id)); + let parent = tree.get_node(id).and_then(|node| node.parent); + if let Some(parent) = parent { + candidates.extend(tree.children(parent)); + } + current = parent; + } +} + +fn add_relational_anchor_scope( + tree: &DomTree, + anchor: NodeId, + reaches: crate::css::InvalidationReaches, + dirty: &mut HashSet, +) { + // Re-cascading the anchor subtree covers anchor-self changes, inheritance, + // and every descendant subject. It is intentionally broader than the + // dependency's exact reach but keeps the retained-style implementation + // independent of selector matching internals. + add_style_subtree(tree, anchor, dirty); + if reaches.contains(crate::css::InvalidationReaches::SIBLINGS) { + add_following_sibling_subtrees(tree, anchor, dirty); + } +} + +/// Apply Gecko-style upward `:has()` invalidation for one child-list or text +/// mutation. Returns false only when the path outside the anchor combines +/// traversals which the renderer's flat reach bits cannot represent soundly. +fn add_relational_tree_invalidation( + tree: &DomTree, + map: &crate::css::InvalidationMap, + mutation: &TreeStyleMutation, + dirty: &mut HashSet, +) -> bool { + if map.relational_invalidations().is_empty() { + return true; + } + let mut candidates = HashSet::new(); + match *mutation { + TreeStyleMutation::Insert { + node, + old_parent, + .. + } => { + add_inserted_relational_anchor_candidates(tree, node, &mut candidates); + if let Some(old_parent) = old_parent { + add_removed_relational_anchor_candidates(tree, old_parent, &mut candidates); + } + } + TreeStyleMutation::Remove { old_parent, .. } => { + add_removed_relational_anchor_candidates(tree, old_parent, &mut candidates); + } + TreeStyleMutation::Text { node, parent } => { + add_inserted_relational_anchor_candidates(tree, node, &mut candidates); + if let Some(parent) = parent { + candidates.insert(parent); + } + } + } + + for invalidation in map.relational_invalidations() { + let triggered = match mutation { + TreeStyleMutation::Remove { .. } => true, + TreeStyleMutation::Insert { node, .. } => { + subtree_may_match_relational_path(tree, invalidation, *node) + || invalidation.unkeyed_subject + || invalidation.sibling_side_effect + || invalidation.structural_side_effect + } + TreeStyleMutation::Text { .. } => invalidation.text_side_effect, }; - let Some(_element) = node.as_element() else { + if !triggered { + continue; + } + for anchor in candidates.iter().copied() { + if !invalidation.anchor_may_match(tree, anchor) { + continue; + } + if invalidation.unrepresentable_outer_path { return false; - }; - if node.get_attribute("id").is_some_and(|id| { - dependencies_include_relational_rule(map, map.id_dependencies(id)) - }) { - return true; } - if node.get_attribute("class").is_some_and(|classes| { - classes.split_whitespace().any(|class| { - dependencies_include_relational_rule(map, map.class_dependencies(class)) - }) - }) { - return true; - } - if dependencies_include_relational_rule( - map, - map.local_name_dependencies( - node.as_element() - .map(|element| element.local.as_ref()) - .unwrap_or_default(), - ), - ) { - return true; - } - node.attrs().is_some_and(|attributes| { - attributes.iter().any(|attribute| { - dependencies_include_relational_rule( - map, - map.attribute_dependencies(&attribute.qualified_name()), - ) - }) - }) - }) + add_relational_anchor_scope( + tree, + anchor, + invalidation.anchor_reaches, + dirty, + ); + } + } + true } /// A child-list change can alter every structural/sibling match under each @@ -2580,12 +2652,7 @@ fn retained_style_plan( { return RetainedStylePlan::Full; } - if subtree_may_affect_relational_selector(tree, map, node) - || old_parent.is_some_and(|parent| { - subtree_may_affect_relational_selector(tree, map, parent) - }) - || subtree_may_affect_relational_selector(tree, map, new_parent) - { + if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) { return RetainedStylePlan::Full; } add_style_subtree(tree, node, &mut dirty); @@ -2600,9 +2667,7 @@ fn retained_style_plan( { return RetainedStylePlan::Full; } - if subtree_may_affect_relational_selector(tree, map, node) - || subtree_may_affect_relational_selector(tree, map, old_parent) - { + if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) { return RetainedStylePlan::Full; } add_tree_parent_scope(tree, old_parent, dirty_parent_siblings, &mut dirty); @@ -2611,7 +2676,7 @@ fn retained_style_plan( if node_is_style_text(tree, node, parent) { return RetainedStylePlan::Full; } - if !map.state_dependencies("has").is_empty() { + if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) { return RetainedStylePlan::Full; } if let Some(parent) = parent { @@ -3037,7 +3102,7 @@ fn layout_dom_once( // The leaf context is the index of a cosmic-text inline formatting // context in `engine`; leaves without text carry no context. - let mut taffy_tree: TaffyTree = TaffyTree::new(); + let mut taffy_tree: TaffyTree = crate::new_taffy_tree(); let mut id_map: HashMap = HashMap::new(); let mut words: HashMap = HashMap::new(); let mut engine = crate::inline::TextEngine::new_with_web_fonts(fonts); @@ -3219,6 +3284,13 @@ fn layout_dom_once( // inherited context for a fresh descendant directly from the // prior computed values, then retain the style byte-for-byte. if let Some(style) = styles.get(&id) { + crate::style::set_grid_calc_context( + style, + style.font_size.or(inh.font_size).unwrap_or(16.0), + root_fs, + vw, + vh, + ); inh.display = style.display; inh.display_contents = style.display_contents; inh.is_inline_block = style.is_inline_block; @@ -3354,6 +3426,8 @@ fn layout_dom_once( ( parent.grid_auto_columns.clone(), parent.grid_auto_rows.clone(), + parent.grid_calc_expressions[2].clone(), + parent.grid_calc_expressions[3].clone(), ) }) .unwrap_or_default() @@ -3365,6 +3439,10 @@ fn layout_dom_once( .as_ref() .map(|tracks| tracks.0.clone()) .unwrap_or_default(); + style.grid_calc_expressions[2] = inherited_grid_auto_tracks + .as_ref() + .map(|tracks| tracks.2.clone()) + .unwrap_or_default(); style.grid_auto_columns_inherit = false; } if style.grid_auto_rows_inherit { @@ -3372,6 +3450,10 @@ fn layout_dom_once( .as_ref() .map(|tracks| tracks.1.clone()) .unwrap_or_default(); + style.grid_calc_expressions[3] = inherited_grid_auto_tracks + .as_ref() + .map(|tracks| tracks.3.clone()) + .unwrap_or_default(); style.grid_auto_rows_inherit = false; } if style.display_inherit { @@ -3419,6 +3501,7 @@ fn layout_dom_once( // em in non-font-size properties is relative to this element's // OWN computed font-size; resolve every relative length now. let em_px = style.font_size.unwrap_or(parent_fs); + crate::style::set_grid_calc_context(style, em_px, root_fs, vw, vh); if let Some(expression) = style.letter_spacing_expression.as_deref() { style.letter_spacing = crate::style::resolve_contextual_length( expression, em_px, root_fs, vw, vh, em_px, @@ -3823,6 +3906,8 @@ fn layout_dom_once( let host_is_table_box = style.is_table_box; let host_grid_auto_columns = style.grid_auto_columns.clone(); let host_grid_auto_rows = style.grid_auto_rows.clone(); + let host_grid_auto_column_calcs = style.grid_calc_expressions[2].clone(); + let host_grid_auto_row_calcs = style.grid_calc_expressions[3].clone(); let host_overflow_x = if style.overflow_scroll_x { 2 } else if style.overflow_clip_x { @@ -3840,10 +3925,12 @@ fn layout_dom_once( let settle_pseudo = |pseudo: &mut crate::LayoutStyle| { if pseudo.grid_auto_columns_inherit { pseudo.grid_auto_columns = host_grid_auto_columns.clone(); + pseudo.grid_calc_expressions[2] = host_grid_auto_column_calcs.clone(); pseudo.grid_auto_columns_inherit = false; } if pseudo.grid_auto_rows_inherit { pseudo.grid_auto_rows = host_grid_auto_rows.clone(); + pseudo.grid_calc_expressions[3] = host_grid_auto_row_calcs.clone(); pseudo.grid_auto_rows_inherit = false; } if pseudo.display_inherit { @@ -3886,6 +3973,7 @@ fn layout_dom_once( pseudo.font_size = Some(host_font_size); } let pseudo_em = pseudo.font_size.unwrap_or(host_font_size); + crate::style::set_grid_calc_context(pseudo, pseudo_em, root_fs, vw, vh); if let Some(expression) = pseudo.letter_spacing_expression.as_deref() { pseudo.letter_spacing = crate::style::resolve_contextual_length( expression, pseudo_em, root_fs, vw, vh, pseudo_em, @@ -13244,11 +13332,11 @@ mod tests { Incremental, ); - // Keyed :has() rules do not poison unrelated mutations, but a matching - // inserted key still takes the correctness-first full path. + // Keyed :has() rules do not poison unrelated mutations. A matching + // insertion walks upward to the keyed anchor and retains clean peers. for (name, inserted_class, expectation) in [ ("unrelated keyed has insertion", "other", Incremental), - ("matching keyed has insertion", "signal", ConservativeFallback), + ("matching keyed has insertion", "signal", Incremental), ] { let html = format!( r#" @@ -13283,7 +13371,7 @@ mod tests { for (name, inserted_tag, expectation) in [ ("unrelated keyed tag has insertion", "i", Incremental), - ("matching keyed tag has insertion", "span", ConservativeFallback), + ("matching keyed tag has insertion", "span", Incremental), ] { let html = format!( r#" @@ -13348,6 +13436,367 @@ mod tests { ); } + #[test] + fn retained_relational_styles_match_forced_full_direction_matrix() { + use RetainedDifferentialExpectation::{ConservativeFallback, Incremental}; + + // Descendant/child traversal and selector-list pseudos all invalidate + // the keyed anchor without cascading an unrelated sibling branch. + for (name, relative) in [ + ("has descendant insertion", ".signal"), + ("has child insertion", "> .signal"), + ("has is insertion", ":is(.signal,.alternate)"), + ("has where insertion", ":where(.signal,.alternate)"), + ] { + let tree = parse_html(&format!( + r#" +
"#, + )); + let target = tree.get_element_by_id("target").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + tree.remove_child(signal); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.append_child(target, signal); + finish_retained_tree_case( + name, + &tree, + &mut cache, + initial, + TreeStyleMutation::Insert { + node: signal, + old_parent: None, + new_parent: target, + }, + Incremental, + ); + } + + // A relative sibling combinator searches toward earlier siblings of + // the changed boundary. Both exact and general sibling paths retain a + // clean branch. + for (name, combinator) in [ + ("has adjacent sibling insertion", "+"), + ("has general sibling insertion", "~"), + ] { + let tree = parse_html(&format!( + r#" +
"#, + )); + let parent = tree.get_element_by_id("parent").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + let clean = tree.get_element_by_id("clean").unwrap(); + tree.remove_child(signal); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.insert_before(clean, signal); + finish_retained_tree_case( + name, + &tree, + &mut cache, + initial, + TreeStyleMutation::Insert { + node: signal, + old_parent: None, + new_parent: parent, + }, + Incremental, + ); + } + + // Inserting an unrelated node can break `+`, even though the inserted + // subtree has none of the selector's indexed keys. + let tree = parse_html( + r#" +
"#, + ); + let host = tree.get_element_by_id("host").unwrap(); + let blocker = tree.get_element_by_id("blocker").unwrap(); + let right = tree.get_element_by_id("right").unwrap(); + tree.remove_child(blocker); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.insert_before(right, blocker); + finish_retained_tree_case( + "has adjacent insertion side effect", + &tree, + &mut cache, + initial, + TreeStyleMutation::Insert { + node: blocker, + old_parent: None, + new_parent: host, + }, + Incremental, + ); + + // Structural matching changes even when the inserted node does not + // carry the keyed relative subject class. + let tree = parse_html( + r#" +
"#, + ); + let host = tree.get_element_by_id("host").unwrap(); + let blocker = tree.get_element_by_id("blocker").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + tree.remove_child(blocker); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.insert_before(signal, blocker); + finish_retained_tree_case( + "has structural insertion side effect", + &tree, + &mut cache, + initial, + TreeStyleMutation::Insert { + node: blocker, + old_parent: None, + new_parent: host, + }, + Incremental, + ); + + // Character data can toggle :empty without changing any indexed key. + let tree = parse_html( + r#" +
x
"#, + ); + let label = tree.get_element_by_id("label").unwrap(); + let text = tree.children(label)[0]; + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.with_node_mut(text, |node| { + if let obscura_dom::tree::NodeData::Text { contents } = &mut node.data { + contents.clear(); + } + }); + finish_retained_tree_case( + "has empty text side effect", + &tree, + &mut cache, + initial, + TreeStyleMutation::Text { + node: text, + parent: Some(label), + }, + Incremental, + ); + + // The old sibling links are gone by the time removal invalidation + // runs. Broadening each old ancestor boundary must cover both a keyed + // removal and an adjacency match created by removing an interloper. + for (name, css, removed_id) in [ + ( + "has descendant removal", + ".host:has(.signal){width:137px}", + "signal", + ), + ( + "has adjacent removal side effect", + ".host:has(> .left + .right){width:139px}", + "blocker", + ), + ] { + let tree = parse_html(&format!( + r#"
"#, + )); + let host = tree.get_element_by_id("host").unwrap(); + let removed = tree.get_element_by_id(removed_id).unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.remove_child(removed); + finish_retained_tree_case( + name, + &tree, + &mut cache, + initial, + TreeStyleMutation::Remove { + node: removed, + old_parent: host, + }, + Incremental, + ); + } + + // Detached nodes are mutable before the queued render flush. Removal + // invalidation must not depend on selector keys still being present in + // the post-mutation subtree. + let tree = parse_html( + r#"
"#, + ); + let host = tree.get_element_by_id("host").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.remove_child(signal); + tree.with_node_mut(signal, |node| { + node.set_attribute("class", "detached-and-renamed".into()) + }); + finish_retained_tree_case( + "has removal after detached key mutation", + &tree, + &mut cache, + initial, + TreeStyleMutation::Remove { + node: signal, + old_parent: host, + }, + Incremental, + ); + + // Reparenting changes anchors in both old and new ancestor chains. + let tree = parse_html( + r#" +
"#, + ); + let old = tree.get_element_by_id("old").unwrap(); + let new = tree.get_element_by_id("new").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.append_child(new, signal); + finish_retained_tree_case( + "has reparent old and new anchors", + &tree, + &mut cache, + initial, + TreeStyleMutation::Insert { + node: signal, + old_parent: Some(old), + new_parent: new, + }, + Incremental, + ); + + // A sibling-then-descendant path outside the anchor is deliberately + // left on the correctness-first full path until dependency chains can + // encode the continuation explicitly. + let tree = parse_html( + r#"
"#, + ); + let host = tree.get_element_by_id("host").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + tree.remove_child(signal); + let mut cache = crate::css::StylesheetCache::default(); + let initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (360.0, 260.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.append_child(host, signal); + finish_retained_tree_case( + "has unrepresentable outer mixed traversal", + &tree, + &mut cache, + initial, + TreeStyleMutation::Insert { + node: signal, + old_parent: None, + new_parent: host, + }, + ConservativeFallback, + ); + } + + #[test] + fn retained_relational_invalidation_keeps_two_thousand_node_peer_clean() { + let mut clean = String::new(); + for index in 0..2_000 { + clean.push_str(&format!("")); + } + let tree = parse_html(&format!( + r#" +
"#, + )); + let host = tree.get_element_by_id("host").unwrap(); + let signal = tree.get_element_by_id("signal").unwrap(); + tree.remove_child(signal); + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.append_child(host, signal); + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut initial.styles), + custom_properties: std::mem::take(&mut initial.custom_properties), + }; + let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &[TreeStyleMutation::Insert { + node: signal, + old_parent: None, + new_parent: host, + } + .into()], + ); + let full = layout_dom(&tree, (800.0, 600.0)); + assert_computed_styles_match("relational 2k clean peer", &incremental, &full); + assert_eq!(incremental.rects, full.rects); + assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}"); + assert!(telemetry.retained_reused >= 2_000, "{telemetry:?}"); + assert!(telemetry.retained_fresh < 24, "{telemetry:?}"); + } + #[test] fn retained_tree_invalidation_keeps_large_unrelated_branch_clean() { let mut clean = String::new(); @@ -14507,6 +14956,28 @@ mod tests { assert_eq!(pseudo.line_height, host.line_height); } + #[test] + fn generated_pseudo_grid_calc_uses_computed_font_and_viewport_context() { + let tree = parse_html( + r#" +
"#, + ); + let laid = layout_dom(&tree, (1000.0, 400.0)); + let host = tree.get_element_by_id("host").unwrap(); + let pseudo = laid.styles[&host].before_pseudo.as_ref().unwrap(); + let calc = &pseudo.grid_calc_expressions[0][0]; + let handle = std::sync::Arc::as_ptr(calc).cast(); + + assert!((crate::style::resolve_grid_calc(handle, 1000.0) - 136.0).abs() < 0.01); + } + #[test] fn positioned_pseudo_does_not_wrap_shrink_to_fit_host_text() { let tree = parse_html( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index acab15a..4952a46 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -2884,6 +2884,66 @@ fn sample_gradient( ] } +/// Whether one shaped inline item can contribute ink to the destination +/// surface. `SwashCache::with_pixels` rasterizes a glyph before its callback +/// can reject out-of-bounds pixels, so letting a long document reach that +/// callback for every offscreen glyph makes viewport screenshots scale with +/// the full page height. Keep this test deliberately conservative: line boxes +/// are expanded by four ems for unusual font ink bounds, and every authored +/// relative-inline offset participates in the envelope. Translation and +/// isolated transform layers arrive through `offset`, in the same coordinate +/// space as the destination pixmap. +fn inline_item_may_intersect_surface( + item: &InlineItem, + offset: (f32, f32), + clip: Option, + surface_height: u32, + raster_scale: f32, +) -> bool { + if !raster_scale.is_finite() || raster_scale <= 0.0 || surface_height == 0 { + return false; + } + let surface_bottom = surface_height as f32 / raster_scale; + if let Some(clip) = clip { + if clip.y >= surface_bottom || clip.y + clip.height <= 0.0 { + return false; + } + } + + let mut line_top = f32::INFINITY; + let mut line_bottom = f32::NEG_INFINITY; + let mut max_font_size = 0.0f32; + for run in item.buffer.layout_runs() { + line_top = line_top.min(run.line_top); + line_bottom = line_bottom.max(run.line_top + run.line_height); + for glyph in run.glyphs { + if glyph.font_size.is_finite() { + max_font_size = max_font_size.max(glyph.font_size.max(0.0)); + } + } + } + if !line_top.is_finite() || !line_bottom.is_finite() { + return false; + } + + // Some fonts have ink well outside their ascender/descender metrics. Four + // ems on each side is intentionally much larger than normal glyph ink, + // while still culling text that is genuinely pages away from the viewport. + let ink_guard = max_font_size.mul_add(4.0, 4.0); + let mut relative_top = 0.0f32; + let mut relative_bottom = 0.0f32; + for range in &item.relative_owner_ranges { + if range.offset.1.is_finite() { + relative_top = relative_top.min(range.offset.1); + relative_bottom = relative_bottom.max(range.offset.1); + } + } + let origin_y = item.origin.1 + offset.1; + let visual_top = origin_y + line_top + relative_top - ink_guard; + let visual_bottom = origin_y + line_bottom + relative_bottom + ink_guard; + visual_top < surface_bottom && visual_bottom > 0.0 +} + impl TextEngine { /// Rasterize inline context `idx` into `pixmap`, honoring its finalized /// clip. Tests and generated items whose coordinates do not change between @@ -2964,6 +3024,16 @@ impl TextEngine { let Some(item) = items.get_mut(idx) else { return; }; + let clip = clip_override.or(item.clip); + if !inline_item_may_intersect_surface( + item, + offset, + clip, + pixmap.height(), + raster_scale, + ) { + return; + } let (ox, oy) = ( (item.origin.0 + offset.0) * raster_scale, (item.origin.1 + offset.1) * raster_scale, @@ -2972,7 +3042,6 @@ impl TextEngine { // but the clip is already in screen space (owner-shifted at // `resolve_clip_rects`) and must not move with the container, or a // translated slide would drag its viewport's clip along with it. - let clip = clip_override.or(item.clip); let pw = pixmap.width() as i32; let ph = pixmap.height() as i32; let clip_bounds = clip.map(|c| { @@ -3356,6 +3425,103 @@ mod tests { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; + fn surface_cull_fixture() -> (TextEngine, usize) { + let tree = obscura_dom::parse_html("

Visible text

"); + let copy = tree.get_element_by_id("copy").unwrap(); + let style = LayoutStyle { + display: Display::Block, + font_size: Some(16.0), + line_height: Some(crate::LineHeight::Px(20.0)), + ..Default::default() + }; + let mut engine = TextEngine::new(); + let item = engine + .try_build(&tree, copy, &HashMap::from([(copy, style)])) + .unwrap(); + engine.finalize(item, (0.0, 10.0), 200.0, None); + (engine, item) + } + + #[test] + fn text_surface_cull_is_conservative_for_offsets_and_ink_overhang() { + let (mut engine, item) = surface_cull_fixture(); + assert!(inline_item_may_intersect_surface( + &engine.items[item], + (0.0, 0.0), + None, + 100, + 1.0, + )); + + engine.items[item].origin.1 = -60.0; + assert!( + inline_item_may_intersect_surface( + &engine.items[item], + (0.0, 0.0), + None, + 100, + 1.0, + ), + "the four-em ink guard must preserve unusual glyph overhang" + ); + engine.items[item].origin.1 = -500.0; + assert!(!inline_item_may_intersect_surface( + &engine.items[item], + (0.0, 0.0), + None, + 100, + 1.0, + )); + engine.items[item].origin.1 = 500.0; + assert!(!inline_item_may_intersect_surface( + &engine.items[item], + (0.0, 0.0), + None, + 100, + 1.0, + )); + assert!( + inline_item_may_intersect_surface( + &engine.items[item], + (0.0, -490.0), + None, + 100, + 1.0, + ), + "the transform/layer translation must be applied before culling" + ); + + engine.items[item] + .relative_owner_ranges + .push(RelativeOwnerTextRange { + start: 0, + end: 1, + offset: (0.0, -490.0), + }); + assert!( + inline_item_may_intersect_surface( + &engine.items[item], + (0.0, 0.0), + None, + 100, + 1.0, + ), + "a relatively positioned inline may move visible ink into the surface" + ); + assert!(!inline_item_may_intersect_surface( + &engine.items[item], + (0.0, 0.0), + Some(Rect { + x: 0.0, + y: 200.0, + width: 100.0, + height: 20.0, + }), + 100, + 1.0, + )); + } + #[test] fn system_ui_resolves_in_stack_order_to_chromium_linux_face() { let stack = "system-ui, -apple-system, \"Segoe UI\", Roboto, \ diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 1c16308..07c31e8 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -1007,6 +1007,10 @@ pub struct LayoutStyle { // resolved to line placements on children in a later pass. pub grid_template_columns: Vec>, pub grid_template_rows: Vec>, + /// Keeps the opaque `calc()` handles embedded in grid track sizing + /// functions alive until every Taffy layout pass has completed. + pub(crate) grid_calc_expressions: + [Vec>; 4], /// Track sizing functions for columns created outside the explicit grid. /// An empty list is the CSS initial `auto` value: taffy supplies one /// automatic implicit track and cycles a non-empty authored list. @@ -1605,7 +1609,15 @@ pub struct NodeRect { /// Lay out `root` within a `viewport` (width, height) in CSS pixels and return /// the border-box geometry per node, mirroring the input tree. pub fn layout(root: &LayoutNode, viewport: (f32, f32)) -> NodeRect { - let mut tree: TaffyTree = TaffyTree::new(); + let root_font_size = root.style.font_size.unwrap_or(16.0); + initialize_grid_calc_contexts( + root, + root_font_size, + root_font_size, + viewport.0 / 100.0, + viewport.1 / 100.0, + ); + let mut tree: TaffyTree = new_taffy_tree(); let root_id = build_node(&mut tree, root); let _ = tree.compute_layout( root_id, @@ -1617,6 +1629,28 @@ pub fn layout(root: &LayoutNode, viewport: (f32, f32)) -> NodeRect { read_node(&tree, root_id) } +fn initialize_grid_calc_contexts( + node: &LayoutNode, + inherited_font_size: f32, + root_font_size: f32, + vw: f32, + vh: f32, +) { + let font_size = node.style.font_size.unwrap_or(inherited_font_size); + crate::style::set_grid_calc_context(&node.style, font_size, root_font_size, vw, vh); + for child in &node.children { + initialize_grid_calc_contexts(child, font_size, root_font_size, vw, vh); + } +} + +pub(crate) fn new_taffy_tree() -> TaffyTree { + let mut tree = TaffyTree::new(); + // Every opaque handle is backed by an Arc retained in the LayoutStyle + // map/input tree, which outlives all computations on `tree`. + tree.set_calc_resolver(crate::style::resolve_grid_calc); + tree +} + fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId { let style = to_taffy_style(&node.style); if node.children.is_empty() { @@ -1960,6 +1994,49 @@ mod tests { ); } + #[test] + fn grid_calc_tracks_resolve_against_container_width() { + let grid = crate::style::compute_style( + "div", + Some( + "display:grid;width:1440px;height:20px;\ + grid-template-columns:\ + minmax(0,calc((100% - (50rem + 20vw))/2)) 1fr \ + minmax(0,calc((100% - (50rem + 20vw))/2))", + ), + ); + let child = || { + LayoutNode::leaf(crate::style::compute_style( + "div", + Some("display:block;height:10px"), + )) + }; + let output = layout( + &LayoutNode { + style: grid.clone(), + text: None, + children: vec![child(), child(), child()], + }, + (1440.0, 100.0), + ); + + assert!((output.children[0].border_box.width - 176.0).abs() < 0.01); + assert!((output.children[1].border_box.x - 176.0).abs() < 0.01); + assert!((output.children[1].border_box.width - 1088.0).abs() < 0.01); + + let resized = layout( + &LayoutNode { + style: grid, + text: None, + children: vec![child(), child(), child()], + }, + (1000.0, 100.0), + ); + assert!((resized.children[0].border_box.width - 220.0).abs() < 0.01); + assert!((resized.children[1].border_box.x - 220.0).abs() < 0.01); + assert!((resized.children[1].border_box.width - 1000.0).abs() < 0.01); + } + #[test] fn block_auto_margins_absorb_horizontal_free_space() { let centered = LayoutStyle { diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index ce78650..88c5c34 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1687,14 +1687,16 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { }; } "grid-template-columns" => { - let (tracks, names) = parse_track_list_named(value); + let (tracks, names, calc_expressions) = parse_track_list_named(value); style.grid_template_columns_subgrid = is_subgrid_track_list(value); style.grid_template_columns = tracks; + style.grid_calc_expressions[0] = calc_expressions; style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names)); } "grid-template-rows" => { - let (tracks, names) = parse_track_list_named(value); + let (tracks, names, calc_expressions) = parse_track_list_named(value); style.grid_template_rows = tracks; + style.grid_calc_expressions[1] = calc_expressions; style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names)); } "grid-auto-columns" => apply_grid_auto_tracks(style, value, true), @@ -2553,7 +2555,7 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool { .all(|token| matches!(*token, "row" | "column" | "dense")) } "grid-template-columns" | "grid-template-rows" => { - let (tracks, _) = parse_track_list_named(value); + let (tracks, _, _) = parse_track_list_named(value); !tracks.is_empty() || lower.starts_with("subgrid") } "grid-template-areas" => !parse_grid_areas(value).is_empty() || lower == "none", @@ -3499,6 +3501,141 @@ fn scale_number(s: &str) -> Option { /// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem /// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the /// parentheses in `minmax(...)` / `fit-content(...)`. +#[repr(align(8))] +#[derive(Debug)] +pub(crate) struct GridCalcExpression { + expression: String, + em_px: std::sync::atomic::AtomicU32, + rem_px: std::sync::atomic::AtomicU32, + vw: std::sync::atomic::AtomicU32, + vh: std::sync::atomic::AtomicU32, + context_initialized: std::sync::atomic::AtomicBool, +} + +impl GridCalcExpression { + fn parse(value: &str) -> Option { + let value = value.trim().to_ascii_lowercase(); + if !(value.starts_with("calc(") + || value.starts_with("min(") + || value.starts_with("max(") + || value.starts_with("clamp(") + || value.starts_with("round(")) + { + return None; + } + + // Track math is resolved late against the actual grid-axis basis. + // Accept only supported length units, numbers, and math function + // names. The computed font and viewport contexts are attached by the + // top-down style pass before Taffy resolves the percentage basis. + for word in value + .split(|character: char| !character.is_ascii_alphabetic() && character != '-') + .filter(|word| !word.is_empty() && *word != "-") + { + if !matches!( + word, + "calc" + | "min" + | "max" + | "clamp" + | "round" + | "nearest" + | "up" + | "down" + | "to-zero" + | "px" + | "pt" + | "em" + | "rem" + | "ex" + | "vw" + | "vh" + | "dvw" + | "dvh" + | "svw" + | "svh" + | "lvw" + | "lvh" + | "vmin" + | "vmax" + ) { + return None; + } + } + + // Probe more than one basis so malformed expressions and non-finite + // arithmetic never become an opaque Taffy handle. + for basis in [0.0, 100.0] { + let resolved = resolve_contextual_length(&value, 16.0, 16.0, 10.0, 10.0, basis)?; + if !resolved.is_finite() { + return None; + } + } + Some(Self { + expression: value, + em_px: std::sync::atomic::AtomicU32::new(16.0f32.to_bits()), + rem_px: std::sync::atomic::AtomicU32::new(16.0f32.to_bits()), + vw: std::sync::atomic::AtomicU32::new(0.0f32.to_bits()), + vh: std::sync::atomic::AtomicU32::new(0.0f32.to_bits()), + context_initialized: std::sync::atomic::AtomicBool::new(false), + }) + } + + fn set_context(&self, em_px: f32, rem_px: f32, vw: f32, vh: f32) { + use std::sync::atomic::Ordering; + // Viewport units are used values and must follow every new layout + // viewport, including repeated calls through the public LayoutNode API. + self.vw.store(vw.to_bits(), Ordering::Relaxed); + self.vh.store(vh.to_bits(), Ordering::Relaxed); + // A cloned handle represents the same computed track value. In + // particular, explicit `inherit` must preserve the parent's em basis + // even when the child has a different font-size. + if self.context_initialized.load(Ordering::Acquire) { + return; + } + self.em_px.store(em_px.to_bits(), Ordering::Relaxed); + self.rem_px.store(rem_px.to_bits(), Ordering::Relaxed); + self.context_initialized.store(true, Ordering::Release); + } +} + +pub(crate) fn set_grid_calc_context( + style: &LayoutStyle, + em_px: f32, + rem_px: f32, + vw: f32, + vh: f32, +) { + for expression in style.grid_calc_expressions.iter().flatten() { + expression.set_context(em_px, rem_px, vw, vh); + } +} + +/// Resolve an Arc-backed grid calc handle installed in Taffy. +/// +/// # Safety +/// +/// `value` must point to a live `GridCalcExpression`. `LayoutStyle` retains +/// the Arc owners for the entire lifetime of every layout tree computation. +#[allow(unsafe_code)] +pub(crate) fn resolve_grid_calc(value: *const (), basis: f32) -> f32 { + // SAFETY: upheld by `new_taffy_tree` and the Arc ownership documented on + // `LayoutStyle::grid_calc_expressions`. + let calc = unsafe { &*value.cast::() }; + use std::sync::atomic::Ordering; + resolve_contextual_length( + &calc.expression, + f32::from_bits(calc.em_px.load(Ordering::Relaxed)), + f32::from_bits(calc.rem_px.load(Ordering::Relaxed)), + f32::from_bits(calc.vw.load(Ordering::Relaxed)), + f32::from_bits(calc.vh.load(Ordering::Relaxed)), + basis, + ) + .filter(|value| value.is_finite()) + .unwrap_or(0.0) + .max(0.0) +} + /// Parse a track list into taffy sizing functions plus the `[line-name]` map /// (name -> 1-based grid line number). `repeat(n, ...)` is expanded to n copies; /// auto-fill/auto-fit remain typed repetitions so layout can derive their count @@ -3509,10 +3646,12 @@ pub(crate) fn parse_track_list_named( ) -> ( Vec>, Vec<(String, i16)>, + Vec>, ) { let tokens = tokenize_tracks(value); let mut tracks = Vec::new(); let mut names = Vec::new(); + let mut calc_expressions = Vec::new(); let mut line: i16 = 1; // A subgridded axis owns line names but no sizing functions. Do not turn // the keyword into the generic unknown-token => auto-track fallback. @@ -3521,15 +3660,22 @@ pub(crate) fn parse_track_list_named( .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); + expand_track_token( + &tok, + &mut tracks, + &mut names, + &mut calc_expressions, + &mut line, + ); } - (tracks, names) + (tracks, names, calc_expressions) } fn expand_track_token( tok: &str, tracks: &mut Vec>, names: &mut Vec<(String, i16)>, + calc_expressions: &mut Vec>, line: &mut i16, ) { let t = tok.trim(); @@ -3554,7 +3700,7 @@ fn expand_track_token( let repeated_tracks = subtoks .iter() .filter(|st| !st.trim_start().starts_with('[')) - .map(|st| track(st)) + .map(|st| track(st, calc_expressions)) .collect(); tracks.push(taffy::GridTemplateComponent::Repeat( taffy::GridTemplateRepetition { @@ -3569,13 +3715,16 @@ fn expand_track_token( let count = cnt.trim().parse::().unwrap_or(1).min(1000); for _ in 0..count { for st in &subtoks { - expand_track_token(st, tracks, names, line); + expand_track_token(st, tracks, names, calc_expressions, line); } } } return; } - tracks.push(taffy::GridTemplateComponent::Single(track(t))); + tracks.push(taffy::GridTemplateComponent::Single(track( + t, + calc_expressions, + ))); *line += 1; } @@ -3626,7 +3775,10 @@ fn tokenize_tracks(value: &str) -> Vec { out } -fn track(tok: &str) -> taffy::TrackSizingFunction { +fn track( + tok: &str, + calc_expressions: &mut Vec>, +) -> taffy::TrackSizingFunction { use taffy::MinMax; let t = tok.trim(); let lower = t.to_ascii_lowercase(); @@ -3634,10 +3786,11 @@ fn track(tok: &str) -> taffy::TrackSizingFunction { .strip_prefix("minmax(") .and_then(|s| s.strip_suffix(')')) { - if let Some((a, b)) = inner.split_once(',') { + let arguments = split_top_level(inner, ','); + if let [a, b] = arguments.as_slice() { return MinMax { - min: min_track(a.trim()), - max: max_track(b.trim()), + min: min_track(a.trim(), calc_expressions), + max: max_track(b.trim(), calc_expressions), }; } } @@ -3645,15 +3798,28 @@ fn track(tok: &str) -> taffy::TrackSizingFunction { .strip_prefix("fit-content(") .and_then(|s| s.strip_suffix(')')) { - let px = px_value(inner.trim()).unwrap_or(0.0); + let limit = inner.trim(); + let max = if let Some(percent) = limit + .strip_suffix('%') + .and_then(|number| number.trim().parse::().ok()) + { + taffy::MaxTrackSizingFunction::fit_content_percent(percent / 100.0) + } else if let Some(px) = px_value(limit) { + taffy::MaxTrackSizingFunction::fit_content_px(px) + } else { + // Taffy has no opaque-calc form for fit-content's distinct clamp + // semantics. Preserve a non-collapsing intrinsic fallback rather + // than turning a valid expression into fit-content(0px). + taffy::MaxTrackSizingFunction::auto() + }; return MinMax { min: taffy::MinTrackSizingFunction::auto(), - max: taffy::MaxTrackSizingFunction::fit_content_px(px), + max, }; } MinMax { - min: min_track(t), - max: max_track(t), + min: min_track(t, calc_expressions), + max: max_track(t, calc_expressions), } } @@ -3661,7 +3827,12 @@ fn track(tok: &str) -> taffy::TrackSizingFunction { /// `grid-auto-rows`. Unlike template track lists, implicit track lists do not /// accept line names or `repeat()`: the authored list itself is repeated as /// needed for successive implicit tracks. -fn parse_grid_auto_track_list(value: &str) -> Option> { +fn parse_grid_auto_track_list( + value: &str, +) -> Option<( + Vec, + Vec>, +)> { let tokens = tokenize_tracks(value); if tokens.is_empty() || tokens.iter().any(|token| { @@ -3678,7 +3849,12 @@ fn parse_grid_auto_track_list(value: &str) -> Option bool { @@ -3716,6 +3892,9 @@ fn valid_grid_track_breadth(value: &str, flex_allowed: bool) -> bool { fn valid_grid_track_length_percentage(value: &str) -> bool { let lower = value.to_ascii_lowercase(); + if GridCalcExpression::parse(&lower).is_some() { + return true; + } if lower == "0" { return true; } @@ -3735,30 +3914,37 @@ fn valid_grid_track_length_percentage(value: &str) -> bool { fn apply_grid_auto_tracks(style: &mut LayoutStyle, value: &str, columns: bool) { let lower = value.trim().to_ascii_lowercase(); - let (tracks, inherit) = match lower.as_str() { - "inherit" => (Vec::new(), true), + let (tracks, inherit, calc_expressions) = match lower.as_str() { + "inherit" => (Vec::new(), true, Vec::new()), // These properties are non-inherited. The compact cascade has no // retained lower-origin/layer declaration for revert, so it follows // the existing non-inherited-property policy and uses the initial // automatic implicit track. - "initial" | "unset" | "revert" | "revert-layer" => (Vec::new(), false), + "initial" | "unset" | "revert" | "revert-layer" => { + (Vec::new(), false, Vec::new()) + } _ => { - let Some(tracks) = parse_grid_auto_track_list(value) else { + let Some((tracks, calc_expressions)) = parse_grid_auto_track_list(value) else { return; }; - (tracks, false) + (tracks, false, calc_expressions) } }; if columns { style.grid_auto_columns = tracks; + style.grid_calc_expressions[2] = calc_expressions; style.grid_auto_columns_inherit = inherit; } else { style.grid_auto_rows = tracks; + style.grid_calc_expressions[3] = calc_expressions; style.grid_auto_rows_inherit = inherit; } } -fn min_track(tok: &str) -> taffy::MinTrackSizingFunction { +fn min_track( + tok: &str, + calc_expressions: &mut Vec>, +) -> taffy::MinTrackSizingFunction { use taffy::MinTrackSizingFunction as M; let lower = tok.to_ascii_lowercase(); match lower.as_str() { @@ -3776,6 +3962,11 @@ fn min_track(tok: &str) -> taffy::MinTrackSizingFunction { M::percent(p / 100.0) } else if let Some(px) = px_value(other) { M::length(px) + } else if let Some(calc) = GridCalcExpression::parse(other) { + let calc = std::sync::Arc::new(calc); + let handle = std::sync::Arc::as_ptr(&calc).cast(); + calc_expressions.push(calc); + M::calc(handle) } else { M::auto() } @@ -3783,7 +3974,10 @@ fn min_track(tok: &str) -> taffy::MinTrackSizingFunction { } } -fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction { +fn max_track( + tok: &str, + calc_expressions: &mut Vec>, +) -> taffy::MaxTrackSizingFunction { use taffy::MaxTrackSizingFunction as M; let lower = tok.to_ascii_lowercase(); match lower.as_str() { @@ -3803,6 +3997,11 @@ fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction { M::percent(p / 100.0) } else if let Some(px) = px_value(other) { M::length(px) + } else if let Some(calc) = GridCalcExpression::parse(other) { + let calc = std::sync::Arc::new(calc); + let handle = std::sync::Arc::as_ptr(&calc).cast(); + calc_expressions.push(calc); + M::calc(handle) } else { M::auto() } @@ -3849,14 +4048,16 @@ fn parse_grid_template(style: &mut LayoutStyle, value: &str) { // Rows side carries area strings interleaved with row track sizes. style.grid_areas = Some(parse_grid_areas(rows_part)); } else if !rows_part.is_empty() { - let (tracks, names) = parse_track_list_named(rows_part); + let (tracks, names, calc_expressions) = parse_track_list_named(rows_part); style.grid_template_rows = tracks; + style.grid_calc_expressions[1] = calc_expressions; style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names)); } if let Some(cols) = cols_part { - let (tracks, names) = parse_track_list_named(cols); + let (tracks, names, calc_expressions) = parse_track_list_named(cols); style.grid_template_columns_subgrid = is_subgrid_track_list(cols); style.grid_template_columns = tracks; + style.grid_calc_expressions[0] = calc_expressions; style.grid_col_line_names = (!names.is_empty()).then(|| build_line_map(names)); } } @@ -3873,9 +4074,11 @@ fn parse_grid_shorthand(style: &mut LayoutStyle, value: &str) { let columns = columns.trim(); if rows.to_ascii_lowercase().contains("auto-flow") { style.grid_template_rows.clear(); - let (tracks, names) = parse_track_list_named(columns); + style.grid_calc_expressions[1].clear(); + let (tracks, names, calc_expressions) = parse_track_list_named(columns); style.grid_template_columns_subgrid = is_subgrid_track_list(columns); style.grid_template_columns = tracks; + style.grid_calc_expressions[0] = calc_expressions; 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") { taffy::GridAutoFlow::RowDense @@ -3884,9 +4087,11 @@ 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_calc_expressions[0].clear(); style.grid_template_columns_subgrid = false; - let (tracks, names) = parse_track_list_named(rows); + let (tracks, names, calc_expressions) = parse_track_list_named(rows); style.grid_template_rows = tracks; + style.grid_calc_expressions[1] = calc_expressions; style.grid_row_line_names = (!names.is_empty()).then(|| build_line_map(names)); style.grid_auto_flow = Some(if columns.to_ascii_lowercase().contains("dense") { taffy::GridAutoFlow::ColumnDense @@ -8512,6 +8717,56 @@ mod tests { assert_eq!(shorthand.grid_template_columns.len(), 3); } + #[test] + fn grid_track_math_is_deferred_to_the_used_axis_basis() { + let style = compute_style( + "div", + Some( + "grid-template-columns:\ + minmax(0,calc((100% - 84rem)/2)) 1fr \ + minmax(0,max(10px,min(calc(25% - 5px),clamp(20px,10%,200px))))", + ), + ); + let owners = &style.grid_calc_expressions[0]; + assert_eq!(owners.len(), 2); + + set_grid_calc_context(&style, 16.0, 16.0, 14.4, 10.0); + let first = std::sync::Arc::as_ptr(&owners[0]).cast(); + assert!((resolve_grid_calc(first, 1440.0) - 48.0).abs() < 0.01); + let nested = std::sync::Arc::as_ptr(&owners[1]).cast(); + assert!((resolve_grid_calc(nested, 1000.0) - 100.0).abs() < 0.01); + } + + #[test] + fn winning_grid_track_declaration_releases_overridden_calc_owners() { + let style = compute_style( + "div", + Some( + "grid-template-columns:calc(50% - 10px);\ + grid-template-rows:calc(25% - 5px);\ + grid-template-columns:1fr", + ), + ); + assert!(style.grid_calc_expressions[0].is_empty()); + // A non-minmax breadth supplies both the minimum and maximum sizing + // function, each with its own stable opaque handle. + assert_eq!(style.grid_calc_expressions[1].len(), 2); + } + + #[test] + fn inherited_grid_calc_preserves_parent_em_context() { + let parent = compute_style("div", Some("grid-auto-columns:calc(1em + 10%)")); + set_grid_calc_context(&parent, 20.0, 16.0, 10.0, 10.0); + + let mut child = compute_style("div", Some("grid-auto-columns:inherit")); + child.grid_auto_columns = parent.grid_auto_columns.clone(); + child.grid_calc_expressions[2] = parent.grid_calc_expressions[2].clone(); + set_grid_calc_context(&child, 40.0, 16.0, 10.0, 10.0); + + let handle = std::sync::Arc::as_ptr(&child.grid_calc_expressions[2][0]).cast(); + assert!((resolve_grid_calc(handle, 100.0) - 30.0).abs() < 0.01); + } + #[test] fn implicit_grid_track_lists_parse_and_css_wide_values_reset_them() { let tracks = compute_style( diff --git a/vendor/taffy/src/tree/taffy_tree.rs b/vendor/taffy/src/tree/taffy_tree.rs index ab9beb0..173b95d 100644 --- a/vendor/taffy/src/tree/taffy_tree.rs +++ b/vendor/taffy/src/tree/taffy_tree.rs @@ -80,11 +80,23 @@ impl std::error::Error for TaffyError {} pub(crate) struct TaffyConfig { /// Whether to round layout values pub(crate) use_rounding: bool, + /// Resolves an opaque `calc()` handle against its percentage basis. + #[cfg(feature = "calc")] + pub(crate) calc_resolver: fn(*const (), f32) -> f32, +} + +#[cfg(feature = "calc")] +fn default_calc_resolver(_value: *const (), _basis: f32) -> f32 { + 0.0 } impl Default for TaffyConfig { fn default() -> Self { - Self { use_rounding: true } + Self { + use_rounding: true, + #[cfg(feature = "calc")] + calc_resolver: default_calc_resolver, + } } } @@ -388,8 +400,8 @@ where } #[inline(always)] - fn resolve_calc_value(&self, _val: *const (), _basis: f32) -> f32 { - 0.0 + fn resolve_calc_value(&self, val: *const (), basis: f32) -> f32 { + (self.taffy.config.calc_resolver)(val, basis) } #[inline(always)] @@ -566,6 +578,16 @@ impl TaffyTree { self.config.use_rounding = false; } + /// Installs the resolver used for opaque `calc()` handles. + /// + /// Every calc handle stored in a node style must be understood by + /// `resolver` and remain alive for every layout computation performed by + /// this tree. Taffy treats handles as opaque and never dereferences them. + #[cfg(feature = "calc")] + pub fn set_calc_resolver(&mut self, resolver: fn(*const (), f32) -> f32) { + self.config.calc_resolver = resolver; + } + /// Creates and adds a new unattached leaf node to the tree, and returns the node of the new node pub fn new_leaf(&mut self, layout: Style) -> TaffyResult { let id = self.nodes.insert(NodeData::new(layout));