Improve responsive layout and retained rendering
This commit is contained in:
1 parent
ede14b7740
commit
50b2ea14ff
6 files changed
+1554
-103
No files matched your search
@@ -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<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.
|
||||
///
|
||||
/// This follows Gecko's conservative invalidation-map shape: live mutations
|
||||
@@ -62,6 +160,7 @@ pub struct InvalidationMap {
|
||||
conservative_rule_orders: Vec<usize>,
|
||||
relational_rule_orders: Vec<usize>,
|
||||
unkeyed_relational_rule_orders: Vec<usize>,
|
||||
relational_invalidations: Vec<RelationalInvalidation>,
|
||||
}
|
||||
|
||||
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<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(
|
||||
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(
|
||||
"<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]
|
||||
fn invalidation_map_includes_generated_content_host_dependencies() {
|
||||
let map = test_invalidation_map(
|
||||
|
||||
@@ -2460,68 +2460,140 @@ fn node_is_style_text(tree: &DomTree, node: NodeId, parent: Option<NodeId>) -> 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<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;
|
||||
};
|
||||
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<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 words: HashMap<taffy::NodeId, (NodeId, String)> = 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#"<style>.host:has(.signal) .dependent{{width:101px}}.clean{{height:19px}}</style>
|
||||
@@ -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#"<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]
|
||||
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#"<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]
|
||||
fn positioned_pseudo_does_not_wrap_shrink_to_fit_host_text() {
|
||||
let tree = parse_html(
|
||||
|
||||
@@ -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 {
|
||||
/// 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("<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]
|
||||
fn system_ui_resolves_in_stack_order_to_chromium_linux_face() {
|
||||
let stack = "system-ui, -apple-system, \"Segoe UI\", Roboto, \
|
||||
|
||||
@@ -1007,6 +1007,10 @@ pub struct LayoutStyle {
|
||||
// resolved to line placements on children in a later pass.
|
||||
pub grid_template_columns: 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.
|
||||
/// 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<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 {
|
||||
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 {
|
||||
|
||||
@@ -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<f32> {
|
||||
/// 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<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
|
||||
/// (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<taffy::GridTemplateComponent<String>>,
|
||||
Vec<(String, i16)>,
|
||||
Vec<std::sync::Arc<GridCalcExpression>>,
|
||||
) {
|
||||
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<taffy::GridTemplateComponent<String>>,
|
||||
names: &mut Vec<(String, i16)>,
|
||||
calc_expressions: &mut Vec<std::sync::Arc<GridCalcExpression>>,
|
||||
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::<usize>().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<String> {
|
||||
out
|
||||
}
|
||||
|
||||
fn track(tok: &str) -> taffy::TrackSizingFunction {
|
||||
fn track(
|
||||
tok: &str,
|
||||
calc_expressions: &mut Vec<std::sync::Arc<GridCalcExpression>>,
|
||||
) -> 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::<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 {
|
||||
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<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);
|
||||
if tokens.is_empty()
|
||||
|| tokens.iter().any(|token| {
|
||||
@@ -3678,7 +3849,12 @@ fn parse_grid_auto_track_list(value: &str) -> Option<Vec<taffy::TrackSizingFunct
|
||||
{
|
||||
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 {
|
||||
@@ -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<std::sync::Arc<GridCalcExpression>>,
|
||||
) -> 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<std::sync::Arc<GridCalcExpression>>,
|
||||
) -> 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(
|
||||
|
||||
Reference in new issue
Block a user