Improve responsive layout and retained rendering

This commit is contained in:
SGavrl committed 2026-08-03 22:59:18 +00:00
1 parent ede14b7740
commit 50b2ea14ff
6 files changed
+1552 -101

No files matched your search

+460
View File
@@ -46,6 +46,104 @@ pub struct InvalidationDependency {
pub reaches: InvalidationReaches, 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<RelationalSelectorKey>,
relative_keys: Vec<RelationalSelectorKey>,
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. /// Selector dependencies retained alongside the compiled stylesheet.
/// ///
/// This follows Gecko's conservative invalidation-map shape: live mutations /// This follows Gecko's conservative invalidation-map shape: live mutations
@@ -62,6 +160,7 @@ pub struct InvalidationMap {
conservative_rule_orders: Vec<usize>, conservative_rule_orders: Vec<usize>,
relational_rule_orders: Vec<usize>, relational_rule_orders: Vec<usize>,
unkeyed_relational_rule_orders: Vec<usize>, unkeyed_relational_rule_orders: Vec<usize>,
relational_invalidations: Vec<RelationalInvalidation>,
} }
impl InvalidationMap { impl InvalidationMap {
@@ -110,6 +209,10 @@ impl InvalidationMap {
!self.unkeyed_relational_rule_orders.is_empty() !self.unkeyed_relational_rule_orders.is_empty()
} }
pub(crate) fn relational_invalidations(&self) -> &[RelationalInvalidation] {
&self.relational_invalidations
}
pub fn dependency_count(&self) -> usize { pub fn dependency_count(&self) -> usize {
self.ids self.ids
.values() .values()
@@ -192,6 +295,12 @@ impl InvalidationMap {
self.unkeyed_relational_rule_orders.push(rule_order); 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( fn compose_invalidation_reach(
@@ -567,6 +676,259 @@ fn relative_selector_subject_has_key(selector: &str) -> bool {
false 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<RelationalSelectorKey> {
if let Some(local_name) = compound_local_name(compound) {
return Some(RelationalSelectorKey::LocalName(local_name));
}
let chars = compound.chars().collect::<Vec<_>>();
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<RelationalSelectorKey>,
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<RelationalSelectorKey>,
) {
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<RelationalSelectorKey>,
) {
if let Some(local_name) = compound_local_name(compound) {
push_relational_selector_key(keys, RelationalSelectorKey::LocalName(local_name));
}
let chars = compound.chars().collect::<Vec<_>>();
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::<String>();
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::<Vec<_>>();
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::<Vec<_>>();
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( fn note_compound_dependencies(
map: &mut InvalidationMap, map: &mut InvalidationMap,
compound: &str, compound: &str,
@@ -688,10 +1050,55 @@ fn note_compound_dependencies(
); );
map.mark_conservative(rule_order); map.mark_conservative(rule_order);
let alternatives = split_selector_list(&arguments); 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| { let unkeyed = alternatives.iter().any(|alternative| {
!relative_selector_subject_has_key(alternative.trim()) !relative_selector_subject_has_key(alternative.trim())
}); });
map.mark_relational(rule_order, unkeyed); 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 { for alternative in alternatives {
note_selector_dependencies( note_selector_dependencies(
map, 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(
"<section id=host class=host></section><section id=other class=other></section>",
);
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] #[test]
fn invalidation_map_includes_generated_content_host_dependencies() { fn invalidation_map_includes_generated_content_host_dependencies() {
let map = test_invalidation_map( let map = test_invalidation_map(
+538 -67
View File
@@ -2460,68 +2460,140 @@ fn node_is_style_text(tree: &DomTree, node: NodeId, parent: Option<NodeId>) -> b
}) })
} }
fn dependencies_include_relational_rule( fn subtree_may_match_relational_path(
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(
tree: &DomTree, tree: &DomTree,
map: &crate::css::InvalidationMap, invalidation: &crate::css::RelationalInvalidation,
root: NodeId, root: NodeId,
) -> bool { ) -> bool {
if map.has_unkeyed_relational_rules() {
return true;
}
std::iter::once(root) std::iter::once(root)
.chain(tree.descendants(root)) .chain(tree.descendants(root))
.any(|id| { .any(|id| invalidation.relative_path_may_match(tree, id))
let Some(node) = tree.get_node(id) else { }
return false;
/// 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<NodeId>,
) {
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<NodeId>,
) {
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<NodeId>,
) {
// 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<NodeId>,
) -> 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; 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| { add_relational_anchor_scope(
classes.split_whitespace().any(|class| { tree,
dependencies_include_relational_rule(map, map.class_dependencies(class)) anchor,
}) invalidation.anchor_reaches,
}) { dirty,
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| { true
attributes.iter().any(|attribute| {
dependencies_include_relational_rule(
map,
map.attribute_dependencies(&attribute.qualified_name()),
)
})
})
})
} }
/// A child-list change can alter every structural/sibling match under each /// A child-list change can alter every structural/sibling match under each
@@ -2580,12 +2652,7 @@ fn retained_style_plan(
{ {
return RetainedStylePlan::Full; return RetainedStylePlan::Full;
} }
if subtree_may_affect_relational_selector(tree, map, node) if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) {
|| old_parent.is_some_and(|parent| {
subtree_may_affect_relational_selector(tree, map, parent)
})
|| subtree_may_affect_relational_selector(tree, map, new_parent)
{
return RetainedStylePlan::Full; return RetainedStylePlan::Full;
} }
add_style_subtree(tree, node, &mut dirty); add_style_subtree(tree, node, &mut dirty);
@@ -2600,9 +2667,7 @@ fn retained_style_plan(
{ {
return RetainedStylePlan::Full; return RetainedStylePlan::Full;
} }
if subtree_may_affect_relational_selector(tree, map, node) if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) {
|| subtree_may_affect_relational_selector(tree, map, old_parent)
{
return RetainedStylePlan::Full; return RetainedStylePlan::Full;
} }
add_tree_parent_scope(tree, old_parent, dirty_parent_siblings, &mut dirty); 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) { if node_is_style_text(tree, node, parent) {
return RetainedStylePlan::Full; return RetainedStylePlan::Full;
} }
if !map.state_dependencies("has").is_empty() { if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) {
return RetainedStylePlan::Full; return RetainedStylePlan::Full;
} }
if let Some(parent) = parent { 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 // The leaf context is the index of a cosmic-text inline formatting
// context in `engine`; leaves without text carry no context. // context in `engine`; leaves without text carry no context.
let mut taffy_tree: TaffyTree<usize> = TaffyTree::new(); let mut taffy_tree: TaffyTree<usize> = crate::new_taffy_tree();
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new(); let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new(); let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new();
let mut engine = crate::inline::TextEngine::new_with_web_fonts(fonts); 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 // inherited context for a fresh descendant directly from the
// prior computed values, then retain the style byte-for-byte. // prior computed values, then retain the style byte-for-byte.
if let Some(style) = styles.get(&id) { 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 = style.display;
inh.display_contents = style.display_contents; inh.display_contents = style.display_contents;
inh.is_inline_block = style.is_inline_block; inh.is_inline_block = style.is_inline_block;
@@ -3354,6 +3426,8 @@ fn layout_dom_once(
( (
parent.grid_auto_columns.clone(), parent.grid_auto_columns.clone(),
parent.grid_auto_rows.clone(), parent.grid_auto_rows.clone(),
parent.grid_calc_expressions[2].clone(),
parent.grid_calc_expressions[3].clone(),
) )
}) })
.unwrap_or_default() .unwrap_or_default()
@@ -3365,6 +3439,10 @@ fn layout_dom_once(
.as_ref() .as_ref()
.map(|tracks| tracks.0.clone()) .map(|tracks| tracks.0.clone())
.unwrap_or_default(); .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; style.grid_auto_columns_inherit = false;
} }
if style.grid_auto_rows_inherit { if style.grid_auto_rows_inherit {
@@ -3372,6 +3450,10 @@ fn layout_dom_once(
.as_ref() .as_ref()
.map(|tracks| tracks.1.clone()) .map(|tracks| tracks.1.clone())
.unwrap_or_default(); .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; style.grid_auto_rows_inherit = false;
} }
if style.display_inherit { if style.display_inherit {
@@ -3419,6 +3501,7 @@ fn layout_dom_once(
// em in non-font-size properties is relative to this element's // em in non-font-size properties is relative to this element's
// OWN computed font-size; resolve every relative length now. // OWN computed font-size; resolve every relative length now.
let em_px = style.font_size.unwrap_or(parent_fs); 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() { if let Some(expression) = style.letter_spacing_expression.as_deref() {
style.letter_spacing = crate::style::resolve_contextual_length( style.letter_spacing = crate::style::resolve_contextual_length(
expression, em_px, root_fs, vw, vh, em_px, 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_is_table_box = style.is_table_box;
let host_grid_auto_columns = style.grid_auto_columns.clone(); let host_grid_auto_columns = style.grid_auto_columns.clone();
let host_grid_auto_rows = style.grid_auto_rows.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 { let host_overflow_x = if style.overflow_scroll_x {
2 2
} else if style.overflow_clip_x { } else if style.overflow_clip_x {
@@ -3840,10 +3925,12 @@ fn layout_dom_once(
let settle_pseudo = |pseudo: &mut crate::LayoutStyle| { let settle_pseudo = |pseudo: &mut crate::LayoutStyle| {
if pseudo.grid_auto_columns_inherit { if pseudo.grid_auto_columns_inherit {
pseudo.grid_auto_columns = host_grid_auto_columns.clone(); 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; pseudo.grid_auto_columns_inherit = false;
} }
if pseudo.grid_auto_rows_inherit { if pseudo.grid_auto_rows_inherit {
pseudo.grid_auto_rows = host_grid_auto_rows.clone(); 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; pseudo.grid_auto_rows_inherit = false;
} }
if pseudo.display_inherit { if pseudo.display_inherit {
@@ -3886,6 +3973,7 @@ fn layout_dom_once(
pseudo.font_size = Some(host_font_size); pseudo.font_size = Some(host_font_size);
} }
let pseudo_em = pseudo.font_size.unwrap_or(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() { if let Some(expression) = pseudo.letter_spacing_expression.as_deref() {
pseudo.letter_spacing = crate::style::resolve_contextual_length( pseudo.letter_spacing = crate::style::resolve_contextual_length(
expression, pseudo_em, root_fs, vw, vh, pseudo_em, expression, pseudo_em, root_fs, vw, vh, pseudo_em,
@@ -13244,11 +13332,11 @@ mod tests {
Incremental, Incremental,
); );
// Keyed :has() rules do not poison unrelated mutations, but a matching // Keyed :has() rules do not poison unrelated mutations. A matching
// inserted key still takes the correctness-first full path. // insertion walks upward to the keyed anchor and retains clean peers.
for (name, inserted_class, expectation) in [ for (name, inserted_class, expectation) in [
("unrelated keyed has insertion", "other", Incremental), ("unrelated keyed has insertion", "other", Incremental),
("matching keyed has insertion", "signal", ConservativeFallback), ("matching keyed has insertion", "signal", Incremental),
] { ] {
let html = format!( let html = format!(
r#"<style>.host:has(.signal) .dependent{{width:101px}}.clean{{height:19px}}</style> r#"<style>.host:has(.signal) .dependent{{width:101px}}.clean{{height:19px}}</style>
@@ -13283,7 +13371,7 @@ mod tests {
for (name, inserted_tag, expectation) in [ for (name, inserted_tag, expectation) in [
("unrelated keyed tag has insertion", "i", Incremental), ("unrelated keyed tag has insertion", "i", Incremental),
("matching keyed tag has insertion", "span", ConservativeFallback), ("matching keyed tag has insertion", "span", Incremental),
] { ] {
let html = format!( let html = format!(
r#"<style>.host:has(> span) .dependent{{width:103px}}.clean{{height:21px}}</style> r#"<style>.host:has(> span) .dependent{{width:103px}}.clean{{height:21px}}</style>
@@ -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#"<style>.host:has({relative}) .dependent{{width:111px;height:7px}}.clean{{height:9px}}</style>
<main><section id=host class=host><div id=target><i id=signal class=signal></i></div><span class=dependent></span></section><aside class=clean></aside></main>"#,
));
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#"<style>.host:has({combinator} .signal){{width:113px;height:11px}}.clean{{height:13px}}</style>
<main id=parent><section id=host class=host></section><i id=signal class=signal></i><aside id=clean class=clean></aside></main>"#,
));
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#"<style>.host:has(> .left + .right){width:127px}.clean{height:15px}</style>
<main><section id=host class=host><i class=left></i><b id=blocker></b><i id=right class=right></i></section><aside class=clean></aside></main>"#,
);
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#"<style>.host:has(> .signal:first-child){width:131px}.clean{height:17px}</style>
<main><section id=host class=host><b id=blocker></b><i id=signal class=signal></i></section><aside class=clean></aside></main>"#,
);
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#"<style>.host:has(.label:empty){width:133px}.clean{height:18px}</style>
<main><section id=host class=host><span id=label class=label>x</span></section><aside class=clean></aside></main>"#,
);
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#"<style>{css}.clean{{height:19px}}</style><main><section id=host class=host><i class=left></i><b id=blocker></b><i class=right></i><i id=signal class=signal></i></section><aside class=clean></aside></main>"#,
));
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#"<style>.host:has(.signal){width:141px}.clean{height:20px}</style><main><section id=host class=host><i id=signal class=signal></i></section><aside class=clean></aside></main>"#,
);
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#"<style>.host:has(> .signal){width:149px}.clean{height:21px}</style>
<main><section id=old class=host><i id=signal class=signal></i></section><section id=new class=host></section><aside class=clean></aside></main>"#,
);
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#"<style>.host:has(.signal)~.panel .leaf{width:151px}</style><main><section id=host class=host><i id=signal class=signal></i></section><div class=panel><b class=leaf></b></div></main>"#,
);
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!("<span class=clean data-index={index}></span>"));
}
let tree = parse_html(&format!(
r#"<style>.host:has(> .signal) .dependent{{width:157px}}.clean{{height:1px}}</style>
<main><section id=host class=host><i id=signal class=signal></i><b class=dependent></b></section><aside>{clean}</aside></main>"#,
));
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] #[test]
fn retained_tree_invalidation_keeps_large_unrelated_branch_clean() { fn retained_tree_invalidation_keeps_large_unrelated_branch_clean() {
let mut clean = String::new(); let mut clean = String::new();
@@ -14507,6 +14956,28 @@ mod tests {
assert_eq!(pseudo.line_height, host.line_height); 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#"<style>
html { font-size:16px }
#host { font-size:20px }
#host::before {
content:""; display:grid;
grid-template-columns:calc(1em + 1rem + 10vw);
}
</style>
<div id="host"></div>"#,
);
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] #[test]
fn positioned_pseudo_does_not_wrap_shrink_to_fit_host_text() { fn positioned_pseudo_does_not_wrap_shrink_to_fit_host_text() {
let tree = parse_html( let tree = parse_html(
+167 -1
View File
@@ -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<Rect>,
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 { impl TextEngine {
/// Rasterize inline context `idx` into `pixmap`, honoring its finalized /// Rasterize inline context `idx` into `pixmap`, honoring its finalized
/// clip. Tests and generated items whose coordinates do not change between /// 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 { let Some(item) = items.get_mut(idx) else {
return; 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) = ( let (ox, oy) = (
(item.origin.0 + offset.0) * raster_scale, (item.origin.0 + offset.0) * raster_scale,
(item.origin.1 + offset.1) * 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 // but the clip is already in screen space (owner-shifted at
// `resolve_clip_rects`) and must not move with the container, or a // `resolve_clip_rects`) and must not move with the container, or a
// translated slide would drag its viewport's clip along with it. // 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 pw = pixmap.width() as i32;
let ph = pixmap.height() as i32; let ph = pixmap.height() as i32;
let clip_bounds = clip.map(|c| { let clip_bounds = clip.map(|c| {
@@ -3356,6 +3425,103 @@ mod tests {
const RED: [u8; 4] = [255, 0, 0, 255]; const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255];
fn surface_cull_fixture() -> (TextEngine, usize) {
let tree = obscura_dom::parse_html("<p id='copy'>Visible text</p>");
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] #[test]
fn system_ui_resolves_in_stack_order_to_chromium_linux_face() { fn system_ui_resolves_in_stack_order_to_chromium_linux_face() {
let stack = "system-ui, -apple-system, \"Segoe UI\", Roboto, \ let stack = "system-ui, -apple-system, \"Segoe UI\", Roboto, \
+78 -1
View File
@@ -1007,6 +1007,10 @@ pub struct LayoutStyle {
// resolved to line placements on children in a later pass. // resolved to line placements on children in a later pass.
pub grid_template_columns: Vec<taffy::GridTemplateComponent<String>>, pub grid_template_columns: Vec<taffy::GridTemplateComponent<String>>,
pub grid_template_rows: Vec<taffy::GridTemplateComponent<String>>, pub grid_template_rows: Vec<taffy::GridTemplateComponent<String>>,
/// 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<std::sync::Arc<crate::style::GridCalcExpression>>; 4],
/// Track sizing functions for columns created outside the explicit grid. /// Track sizing functions for columns created outside the explicit grid.
/// An empty list is the CSS initial `auto` value: taffy supplies one /// An empty list is the CSS initial `auto` value: taffy supplies one
/// automatic implicit track and cycles a non-empty authored list. /// 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 /// Lay out `root` within a `viewport` (width, height) in CSS pixels and return
/// the border-box geometry per node, mirroring the input tree. /// the border-box geometry per node, mirroring the input tree.
pub fn layout(root: &LayoutNode, viewport: (f32, f32)) -> NodeRect { 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 root_id = build_node(&mut tree, root);
let _ = tree.compute_layout( let _ = tree.compute_layout(
root_id, root_id,
@@ -1617,6 +1629,28 @@ pub fn layout(root: &LayoutNode, viewport: (f32, f32)) -> NodeRect {
read_node(&tree, root_id) 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<NodeContext>() -> TaffyTree<NodeContext> {
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 { fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId {
let style = to_taffy_style(&node.style); let style = to_taffy_style(&node.style);
if node.children.is_empty() { 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] #[test]
fn block_auto_margins_absorb_horizontal_free_space() { fn block_auto_margins_absorb_horizontal_free_space() {
let centered = LayoutStyle { let centered = LayoutStyle {
+284 -29
View File
@@ -1687,14 +1687,16 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}; };
} }
"grid-template-columns" => { "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_subgrid = is_subgrid_track_list(value);
style.grid_template_columns = tracks; 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_col_line_names = (!names.is_empty()).then(|| build_line_map(names));
} }
"grid-template-rows" => { "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_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_row_line_names = (!names.is_empty()).then(|| build_line_map(names));
} }
"grid-auto-columns" => apply_grid_auto_tracks(style, value, true), "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")) .all(|token| matches!(*token, "row" | "column" | "dense"))
} }
"grid-template-columns" | "grid-template-rows" => { "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") !tracks.is_empty() || lower.starts_with("subgrid")
} }
"grid-template-areas" => !parse_grid_areas(value).is_empty() || lower == "none", "grid-template-areas" => !parse_grid_areas(value).is_empty() || lower == "none",
@@ -3499,6 +3501,141 @@ fn scale_number(s: &str) -> Option<f32> {
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem /// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the /// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
/// parentheses in `minmax(...)` / `fit-content(...)`. /// 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<Self> {
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::<GridCalcExpression>() };
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 /// 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; /// (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 /// 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<taffy::GridTemplateComponent<String>>, Vec<taffy::GridTemplateComponent<String>>,
Vec<(String, i16)>, Vec<(String, i16)>,
Vec<std::sync::Arc<GridCalcExpression>>,
) { ) {
let tokens = tokenize_tracks(value); let tokens = tokenize_tracks(value);
let mut tracks = Vec::new(); let mut tracks = Vec::new();
let mut names = Vec::new(); let mut names = Vec::new();
let mut calc_expressions = Vec::new();
let mut line: i16 = 1; let mut line: i16 = 1;
// A subgridded axis owns line names but no sizing functions. Do not turn // A subgridded axis owns line names but no sizing functions. Do not turn
// the keyword into the generic unknown-token => auto-track fallback. // 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")) .map(|token| token.eq_ignore_ascii_case("subgrid"))
.unwrap_or(false); .unwrap_or(false);
for tok in tokens.into_iter().skip(usize::from(is_subgrid)) { 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( fn expand_track_token(
tok: &str, tok: &str,
tracks: &mut Vec<taffy::GridTemplateComponent<String>>, tracks: &mut Vec<taffy::GridTemplateComponent<String>>,
names: &mut Vec<(String, i16)>, names: &mut Vec<(String, i16)>,
calc_expressions: &mut Vec<std::sync::Arc<GridCalcExpression>>,
line: &mut i16, line: &mut i16,
) { ) {
let t = tok.trim(); let t = tok.trim();
@@ -3554,7 +3700,7 @@ fn expand_track_token(
let repeated_tracks = subtoks let repeated_tracks = subtoks
.iter() .iter()
.filter(|st| !st.trim_start().starts_with('[')) .filter(|st| !st.trim_start().starts_with('['))
.map(|st| track(st)) .map(|st| track(st, calc_expressions))
.collect(); .collect();
tracks.push(taffy::GridTemplateComponent::Repeat( tracks.push(taffy::GridTemplateComponent::Repeat(
taffy::GridTemplateRepetition { taffy::GridTemplateRepetition {
@@ -3569,13 +3715,16 @@ fn expand_track_token(
let count = cnt.trim().parse::<usize>().unwrap_or(1).min(1000); let count = cnt.trim().parse::<usize>().unwrap_or(1).min(1000);
for _ in 0..count { for _ in 0..count {
for st in &subtoks { for st in &subtoks {
expand_track_token(st, tracks, names, line); expand_track_token(st, tracks, names, calc_expressions, line);
} }
} }
} }
return; return;
} }
tracks.push(taffy::GridTemplateComponent::Single(track(t))); tracks.push(taffy::GridTemplateComponent::Single(track(
t,
calc_expressions,
)));
*line += 1; *line += 1;
} }
@@ -3626,7 +3775,10 @@ fn tokenize_tracks(value: &str) -> Vec<String> {
out out
} }
fn track(tok: &str) -> taffy::TrackSizingFunction { fn track(
tok: &str,
calc_expressions: &mut Vec<std::sync::Arc<GridCalcExpression>>,
) -> taffy::TrackSizingFunction {
use taffy::MinMax; use taffy::MinMax;
let t = tok.trim(); let t = tok.trim();
let lower = t.to_ascii_lowercase(); let lower = t.to_ascii_lowercase();
@@ -3634,10 +3786,11 @@ fn track(tok: &str) -> taffy::TrackSizingFunction {
.strip_prefix("minmax(") .strip_prefix("minmax(")
.and_then(|s| s.strip_suffix(')')) .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 { return MinMax {
min: min_track(a.trim()), min: min_track(a.trim(), calc_expressions),
max: max_track(b.trim()), max: max_track(b.trim(), calc_expressions),
}; };
} }
} }
@@ -3645,15 +3798,28 @@ fn track(tok: &str) -> taffy::TrackSizingFunction {
.strip_prefix("fit-content(") .strip_prefix("fit-content(")
.and_then(|s| s.strip_suffix(')')) .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::<f32>().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 { return MinMax {
min: taffy::MinTrackSizingFunction::auto(), min: taffy::MinTrackSizingFunction::auto(),
max: taffy::MaxTrackSizingFunction::fit_content_px(px), max,
}; };
} }
MinMax { MinMax {
min: min_track(t), min: min_track(t, calc_expressions),
max: max_track(t), 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 /// `grid-auto-rows`. Unlike template track lists, implicit track lists do not
/// accept line names or `repeat()`: the authored list itself is repeated as /// accept line names or `repeat()`: the authored list itself is repeated as
/// needed for successive implicit tracks. /// needed for successive implicit tracks.
fn parse_grid_auto_track_list(value: &str) -> Option<Vec<taffy::TrackSizingFunction>> { fn parse_grid_auto_track_list(
value: &str,
) -> Option<(
Vec<taffy::TrackSizingFunction>,
Vec<std::sync::Arc<GridCalcExpression>>,
)> {
let tokens = tokenize_tracks(value); let tokens = tokenize_tracks(value);
if tokens.is_empty() if tokens.is_empty()
|| tokens.iter().any(|token| { || tokens.iter().any(|token| {
@@ -3678,7 +3849,12 @@ fn parse_grid_auto_track_list(value: &str) -> Option<Vec<taffy::TrackSizingFunct
{ {
return None; return None;
} }
Some(tokens.iter().map(|token| track(token)).collect()) let mut calc_expressions = Vec::new();
let tracks = tokens
.iter()
.map(|token| track(token, &mut calc_expressions))
.collect();
Some((tracks, calc_expressions))
} }
fn valid_grid_auto_track(value: &str) -> bool { fn valid_grid_auto_track(value: &str) -> 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 { fn valid_grid_track_length_percentage(value: &str) -> bool {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
if GridCalcExpression::parse(&lower).is_some() {
return true;
}
if lower == "0" { if lower == "0" {
return true; 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) { fn apply_grid_auto_tracks(style: &mut LayoutStyle, value: &str, columns: bool) {
let lower = value.trim().to_ascii_lowercase(); let lower = value.trim().to_ascii_lowercase();
let (tracks, inherit) = match lower.as_str() { let (tracks, inherit, calc_expressions) = match lower.as_str() {
"inherit" => (Vec::new(), true), "inherit" => (Vec::new(), true, Vec::new()),
// These properties are non-inherited. The compact cascade has no // These properties are non-inherited. The compact cascade has no
// retained lower-origin/layer declaration for revert, so it follows // retained lower-origin/layer declaration for revert, so it follows
// the existing non-inherited-property policy and uses the initial // the existing non-inherited-property policy and uses the initial
// automatic implicit track. // 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; return;
}; };
(tracks, false) (tracks, false, calc_expressions)
} }
}; };
if columns { if columns {
style.grid_auto_columns = tracks; style.grid_auto_columns = tracks;
style.grid_calc_expressions[2] = calc_expressions;
style.grid_auto_columns_inherit = inherit; style.grid_auto_columns_inherit = inherit;
} else { } else {
style.grid_auto_rows = tracks; style.grid_auto_rows = tracks;
style.grid_calc_expressions[3] = calc_expressions;
style.grid_auto_rows_inherit = inherit; style.grid_auto_rows_inherit = inherit;
} }
} }
fn min_track(tok: &str) -> taffy::MinTrackSizingFunction { fn min_track(
tok: &str,
calc_expressions: &mut Vec<std::sync::Arc<GridCalcExpression>>,
) -> taffy::MinTrackSizingFunction {
use taffy::MinTrackSizingFunction as M; use taffy::MinTrackSizingFunction as M;
let lower = tok.to_ascii_lowercase(); let lower = tok.to_ascii_lowercase();
match lower.as_str() { match lower.as_str() {
@@ -3776,6 +3962,11 @@ fn min_track(tok: &str) -> taffy::MinTrackSizingFunction {
M::percent(p / 100.0) M::percent(p / 100.0)
} else if let Some(px) = px_value(other) { } else if let Some(px) = px_value(other) {
M::length(px) 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 { } else {
M::auto() 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<std::sync::Arc<GridCalcExpression>>,
) -> taffy::MaxTrackSizingFunction {
use taffy::MaxTrackSizingFunction as M; use taffy::MaxTrackSizingFunction as M;
let lower = tok.to_ascii_lowercase(); let lower = tok.to_ascii_lowercase();
match lower.as_str() { match lower.as_str() {
@@ -3803,6 +3997,11 @@ fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction {
M::percent(p / 100.0) M::percent(p / 100.0)
} else if let Some(px) = px_value(other) { } else if let Some(px) = px_value(other) {
M::length(px) 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 { } else {
M::auto() 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. // Rows side carries area strings interleaved with row track sizes.
style.grid_areas = Some(parse_grid_areas(rows_part)); style.grid_areas = Some(parse_grid_areas(rows_part));
} else if !rows_part.is_empty() { } 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_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_row_line_names = (!names.is_empty()).then(|| build_line_map(names));
} }
if let Some(cols) = cols_part { 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_subgrid = is_subgrid_track_list(cols);
style.grid_template_columns = tracks; 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_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(); let columns = columns.trim();
if rows.to_ascii_lowercase().contains("auto-flow") { if rows.to_ascii_lowercase().contains("auto-flow") {
style.grid_template_rows.clear(); 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_subgrid = is_subgrid_track_list(columns);
style.grid_template_columns = tracks; 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_col_line_names = (!names.is_empty()).then(|| build_line_map(names));
style.grid_auto_flow = Some(if rows.to_ascii_lowercase().contains("dense") { style.grid_auto_flow = Some(if rows.to_ascii_lowercase().contains("dense") {
taffy::GridAutoFlow::RowDense 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") { } else if columns.to_ascii_lowercase().contains("auto-flow") {
style.grid_template_columns.clear(); style.grid_template_columns.clear();
style.grid_calc_expressions[0].clear();
style.grid_template_columns_subgrid = false; 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_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_row_line_names = (!names.is_empty()).then(|| build_line_map(names));
style.grid_auto_flow = Some(if columns.to_ascii_lowercase().contains("dense") { style.grid_auto_flow = Some(if columns.to_ascii_lowercase().contains("dense") {
taffy::GridAutoFlow::ColumnDense taffy::GridAutoFlow::ColumnDense
@@ -8512,6 +8717,56 @@ mod tests {
assert_eq!(shorthand.grid_template_columns.len(), 3); 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] #[test]
fn implicit_grid_track_lists_parse_and_css_wide_values_reset_them() { fn implicit_grid_track_lists_parse_and_css_wide_values_reset_them() {
let tracks = compute_style( let tracks = compute_style(
+25 -3
View File
@@ -80,11 +80,23 @@ impl std::error::Error for TaffyError {}
pub(crate) struct TaffyConfig { pub(crate) struct TaffyConfig {
/// Whether to round layout values /// Whether to round layout values
pub(crate) use_rounding: bool, 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 { impl Default for TaffyConfig {
fn default() -> Self { 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)] #[inline(always)]
fn resolve_calc_value(&self, _val: *const (), _basis: f32) -> f32 { fn resolve_calc_value(&self, val: *const (), basis: f32) -> f32 {
0.0 (self.taffy.config.calc_resolver)(val, basis)
} }
#[inline(always)] #[inline(always)]
@@ -566,6 +578,16 @@ impl<NodeContext> TaffyTree<NodeContext> {
self.config.use_rounding = false; 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 /// 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<NodeId> { pub fn new_leaf(&mut self, layout: Style) -> TaffyResult<NodeId> {
let id = self.nodes.insert(NodeData::new(layout)); let id = self.nodes.insert(NodeData::new(layout));