Skip to main content

azul_core/
style.rs

1//! DOM tree to CSS style tree cascading.
2//!
3//! Implements CSS selector matching (`matches_html_element`) and cascade-info
4//! construction (`construct_html_cascade_tree`). Used by `styled_dom` and
5//! `prop_cache` to resolve which CSS rules apply to each DOM node.
6
7use alloc::vec::Vec;
8
9use azul_css::css::{
10    AttributeMatchOp, CssAttributeSelector, CssContentGroup, CssNthChildSelector,
11    CssNthChildSelector::*, CssPath, CssPathPseudoSelector, CssPathSelector,
12};
13
14use crate::{
15    dom::NodeData,
16    id::{NodeDataContainer, NodeDataContainerRef, NodeHierarchyRef, NodeId},
17    styled_dom::NodeHierarchyItem,
18};
19
20/// Has all the necessary information about the style CSS path
21#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22#[repr(C)]
23pub struct CascadeInfo {
24    pub index_in_parent: u32,
25    pub is_last_child: bool,
26}
27
28impl_option!(
29    CascadeInfo,
30    OptionCascadeInfo,
31    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
32);
33
34impl_vec!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor, CascadeInfoVecDestructorType, CascadeInfoVecSlice, OptionCascadeInfo);
35impl_vec_mut!(CascadeInfo, CascadeInfoVec);
36impl_vec_debug!(CascadeInfo, CascadeInfoVec);
37impl_vec_partialord!(CascadeInfo, CascadeInfoVec);
38impl_vec_clone!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor);
39impl_vec_partialeq!(CascadeInfo, CascadeInfoVec);
40
41impl CascadeInfoVec {
42    pub fn as_container<'a>(&'a self) -> NodeDataContainerRef<'a, CascadeInfo> {
43        NodeDataContainerRef {
44            internal: self.as_ref(),
45        }
46    }
47}
48
49/// Returns if the style CSS path matches the DOM node (i.e. if the DOM node should be styled by
50/// that element)
51pub fn matches_html_element(
52    css_path: &CssPath,
53    node_id: NodeId,
54    node_hierarchy: &NodeDataContainerRef<NodeHierarchyItem>,
55    node_data: &NodeDataContainerRef<NodeData>,
56    html_node_tree: &NodeDataContainerRef<CascadeInfo>,
57    expected_path_ending: Option<CssPathPseudoSelector>,
58) -> bool {
59    use self::CssGroupSplitReason::*;
60
61    if css_path.selectors.is_empty() {
62        return false;
63    }
64
65    // Skip anonymous nodes - they are not part of the original DOM tree
66    // and should not participate in CSS selector matching
67    if node_data[node_id].is_anonymous() {
68        return false;
69    }
70
71    // Collect all selector groups (processed right-to-left from the CSS path).
72    let groups: Vec<(CssContentGroup<'_>, CssGroupSplitReason)> =
73        CssGroupIterator::new(css_path.selectors.as_ref()).collect();
74
75    if groups.is_empty() {
76        return false;
77    }
78
79    // The rightmost group must match the target node directly.
80    let (ref first_group, first_reason) = groups[0];
81    let is_last_content_group = groups.len() == 1;
82    if !selector_group_matches(
83        first_group,
84        &html_node_tree[node_id],
85        &node_data[node_id],
86        &expected_path_ending,
87        is_last_content_group,
88    ) {
89        return false;
90    }
91
92    // Navigate from the target node upward/sideways through the DOM,
93    // matching each remaining selector group with its combinator.
94    let mut current_node = node_id;
95
96    for (group_idx, (content_group, _reason)) in groups.iter().enumerate().skip(1) {
97        // The combinator comes from the PREVIOUS group's reason
98        let combinator = groups[group_idx - 1].1;
99        let is_last = group_idx == groups.len() - 1;
100
101        match combinator {
102            DirectChildren => {
103                // Parent must match directly (child combinator `>`)
104                let parent = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
105                match parent {
106                    Some(p) if selector_group_matches(
107                        content_group, &html_node_tree[p], &node_data[p],
108                        &expected_path_ending, is_last,
109                    ) => { current_node = p; }
110                    _ => return false,
111                }
112            }
113            Children => {
114                // Search up ancestor chain for a match (descendant combinator ` `)
115                let mut ancestor = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
116                let mut found = false;
117                while let Some(anc) = ancestor {
118                    if selector_group_matches(
119                        content_group, &html_node_tree[anc], &node_data[anc],
120                        &expected_path_ending, is_last,
121                    ) {
122                        current_node = anc;
123                        found = true;
124                        break;
125                    }
126                    ancestor = find_non_anonymous_parent(anc, node_hierarchy, node_data);
127                }
128                if !found {
129                    return false;
130                }
131            }
132            AdjacentSibling => {
133                // Immediate previous sibling must match (adjacent sibling `+`)
134                let sibling = find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
135                match sibling {
136                    Some(s) if selector_group_matches(
137                        content_group, &html_node_tree[s], &node_data[s],
138                        &expected_path_ending, is_last,
139                    ) => { current_node = s; }
140                    _ => return false,
141                }
142            }
143            GeneralSibling => {
144                // Search previous siblings for a match (general sibling `~`)
145                let mut sibling = find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
146                let mut found = false;
147                while let Some(sib) = sibling {
148                    if selector_group_matches(
149                        content_group, &html_node_tree[sib], &node_data[sib],
150                        &expected_path_ending, is_last,
151                    ) {
152                        current_node = sib;
153                        found = true;
154                        break;
155                    }
156                    sibling = find_non_anonymous_prev_sibling(sib, node_hierarchy, node_data);
157                }
158                if !found {
159                    return false;
160                }
161            }
162        }
163    }
164
165    true
166}
167
168/// Find the first non-anonymous parent of a node.
169fn find_non_anonymous_parent(
170    node_id: NodeId,
171    node_hierarchy: &NodeDataContainerRef<NodeHierarchyItem>,
172    node_data: &NodeDataContainerRef<NodeData>,
173) -> Option<NodeId> {
174    let mut next = node_hierarchy[node_id].parent_id();
175    while let Some(n) = next {
176        if !node_data[n].is_anonymous() {
177            return Some(n);
178        }
179        next = node_hierarchy[n].parent_id();
180    }
181    None
182}
183
184/// Find the first non-anonymous previous sibling of a node.
185fn find_non_anonymous_prev_sibling(
186    node_id: NodeId,
187    node_hierarchy: &NodeDataContainerRef<NodeHierarchyItem>,
188    node_data: &NodeDataContainerRef<NodeData>,
189) -> Option<NodeId> {
190    let mut next = node_hierarchy[node_id].previous_sibling_id();
191    while let Some(n) = next {
192        if !node_data[n].is_anonymous() {
193            return Some(n);
194        }
195        next = node_hierarchy[n].previous_sibling_id();
196    }
197    None
198}
199
200/// A CSS group is a group of css selectors in a path that specify the rule that a
201/// certain node has to match, i.e. "div.main.foo" has to match three requirements:
202///
203/// - the node has to be of type div
204/// - the node has to have the class "main"
205/// - the node has to have the class "foo"
206///
207/// If any of these requirements are not met, the CSS block is discarded.
208///
209/// The CssGroupIterator splits the CSS path into semantic blocks, i.e.:
210///
211/// "body > .foo.main > #baz" will be split into ["body", ".foo.main" and "#baz"]
212pub struct CssGroupIterator<'a> {
213    pub css_path: &'a [CssPathSelector],
214    current_idx: usize,
215    last_reason: CssGroupSplitReason,
216}
217
218#[derive(Debug, Copy, Clone, PartialEq, Eq)]
219pub enum CssGroupSplitReason {
220    /// ".foo .main" - match any children
221    Children,
222    /// ".foo > .main" - match only direct children
223    DirectChildren,
224    /// ".foo + .main" - match adjacent sibling (immediately preceding)
225    AdjacentSibling,
226    /// ".foo ~ .main" - match general sibling (any preceding sibling)
227    GeneralSibling,
228}
229
230impl<'a> CssGroupIterator<'a> {
231    pub fn new(css_path: &'a [CssPathSelector]) -> Self {
232        let initial_len = css_path.len();
233        Self {
234            css_path,
235            current_idx: initial_len,
236            last_reason: CssGroupSplitReason::Children,
237        }
238    }
239}
240
241impl<'a> Iterator for CssGroupIterator<'a> {
242    type Item = (CssContentGroup<'a>, CssGroupSplitReason);
243
244    fn next(&mut self) -> Option<(CssContentGroup<'a>, CssGroupSplitReason)> {
245        use self::CssPathSelector::*;
246
247        let mut new_idx = self.current_idx;
248
249        if new_idx == 0 {
250            return None;
251        }
252
253        let mut current_path = Vec::new();
254
255        while new_idx != 0 {
256            match self.css_path.get(new_idx - 1)? {
257                Children => {
258                    self.last_reason = CssGroupSplitReason::Children;
259                    break;
260                }
261                DirectChildren => {
262                    self.last_reason = CssGroupSplitReason::DirectChildren;
263                    break;
264                }
265                AdjacentSibling => {
266                    self.last_reason = CssGroupSplitReason::AdjacentSibling;
267                    break;
268                }
269                GeneralSibling => {
270                    self.last_reason = CssGroupSplitReason::GeneralSibling;
271                    break;
272                }
273                other => current_path.push(other),
274            }
275            new_idx -= 1;
276        }
277
278        // NOTE: Order inside of a ContentGroup is not important
279        // for matching elements, only important for testing
280        #[cfg(test)]
281        current_path.reverse();
282
283        if new_idx == 0 {
284            if current_path.is_empty() {
285                None
286            } else {
287                // Last element of path
288                self.current_idx = 0;
289                Some((current_path, self.last_reason))
290            }
291        } else {
292            // skip the "Children | DirectChildren" element itself
293            self.current_idx = new_idx - 1;
294            Some((current_path, self.last_reason))
295        }
296    }
297}
298
299pub fn construct_html_cascade_tree(
300    node_hierarchy: &NodeHierarchyRef,
301    node_depths_sorted: &[(usize, NodeId)],
302    node_data: &NodeDataContainerRef<NodeData>,
303) -> NodeDataContainer<CascadeInfo> {
304    let mut nodes = (0..node_hierarchy.len())
305        .map(|_| CascadeInfo {
306            index_in_parent: 0,
307            is_last_child: false,
308        })
309        .collect::<Vec<_>>();
310
311    for (_depth, parent_id) in node_depths_sorted {
312        // Per CSS Selectors Level 4 ยง13: "Standalone text and other non-element
313        // nodes are not counted when calculating the position of an element in
314        // the list of children of its parent."
315        //
316        // We count only element siblings when computing index_in_parent.
317        let element_index_in_parent = parent_id
318            .preceding_siblings(node_hierarchy)
319            .filter(|sib_id| !node_data[*sib_id].is_text_node())
320            .count();
321
322        let parent_html_matcher = CascadeInfo {
323            index_in_parent: (element_index_in_parent.saturating_sub(1)) as u32,
324            // Necessary for :last selectors โ€” find last element sibling
325            is_last_child: {
326                let mut is_last_element = true;
327                let mut next = node_hierarchy[*parent_id].next_sibling;
328                while let Some(sib_id) = next {
329                    if !node_data[sib_id].is_text_node() {
330                        is_last_element = false;
331                        break;
332                    }
333                    next = node_hierarchy[sib_id].next_sibling;
334                }
335                is_last_element
336            },
337        };
338
339        nodes[parent_id.index()] = parent_html_matcher;
340
341        // Count only element children for index_in_parent
342        let mut element_idx: u32 = 0;
343        for child_id in parent_id.children(node_hierarchy) {
344            let is_text = node_data[child_id].is_text_node();
345
346            // Find whether this is the last element child (skip trailing text nodes)
347            let is_last_element_child = if is_text {
348                false
349            } else {
350                let mut is_last = true;
351                let mut next = node_hierarchy[child_id].next_sibling;
352                while let Some(sib_id) = next {
353                    if !node_data[sib_id].is_text_node() {
354                        is_last = false;
355                        break;
356                    }
357                    next = node_hierarchy[sib_id].next_sibling;
358                }
359                is_last
360            };
361
362            let child_html_matcher = CascadeInfo {
363                index_in_parent: element_idx,
364                is_last_child: is_last_element_child,
365            };
366
367            nodes[child_id.index()] = child_html_matcher;
368
369            if !is_text {
370                element_idx += 1;
371            }
372        }
373    }
374
375    NodeDataContainer { internal: nodes }
376}
377
378/// Checks whether the last selector in `path` matches the given pseudo-selector `target`.
379///
380/// Known limitation: this only inspects the final selector in the path, so compound
381/// selectors like `div:hover:first-child` may not be filtered correctly when `target`
382/// is `None` โ€” only the very last pseudo-selector is tested.
383#[inline]
384pub fn rule_ends_with(path: &CssPath, target: Option<CssPathPseudoSelector>) -> bool {
385    // Helper to check if a pseudo-selector is "interactive" (requires user interaction state)
386    // vs "structural" (based on DOM structure only)
387    fn is_interactive_pseudo(p: &CssPathPseudoSelector) -> bool {
388        matches!(
389            p,
390            CssPathPseudoSelector::Hover
391                | CssPathPseudoSelector::Active
392                | CssPathPseudoSelector::Focus
393                | CssPathPseudoSelector::Backdrop
394                | CssPathPseudoSelector::Dragging
395                | CssPathPseudoSelector::DragOver
396        )
397    }
398
399    match target {
400        None => match path.selectors.as_ref().last() {
401            None => false,
402            Some(q) => match q {
403                // Only reject interactive pseudo-selectors (hover, active, focus)
404                // Structural pseudo-selectors (nth-child, first, last) should be allowed
405                CssPathSelector::PseudoSelector(p) => !is_interactive_pseudo(p),
406                _ => true,
407            },
408        },
409        Some(s) => match path.selectors.as_ref().last() {
410            None => false,
411            Some(q) => match q {
412                CssPathSelector::PseudoSelector(q) => *q == s,
413                _ => false,
414            },
415        },
416    }
417}
418
419/// Matches a single group of CSS selectors against a DOM node.
420///
421/// Returns true if all selectors in the group match the given node.
422/// Combinator selectors (>, +, ~, space) should not appear in the group.
423fn selector_group_matches(
424    selectors: &[&CssPathSelector],
425    html_node: &CascadeInfo,
426    node_data: &NodeData,
427    expected_path_ending: &Option<CssPathPseudoSelector>,
428    is_last_content_group: bool,
429) -> bool {
430    selectors.iter().all(|selector| {
431        match_single_selector(
432            selector,
433            html_node,
434            node_data,
435            expected_path_ending,
436            is_last_content_group,
437        )
438    })
439}
440
441/// Matches a single CSS selector against a DOM node.
442fn match_single_selector(
443    selector: &CssPathSelector,
444    html_node: &CascadeInfo,
445    node_data: &NodeData,
446    expected_path_ending: &Option<CssPathPseudoSelector>,
447    is_last_content_group: bool,
448) -> bool {
449    use self::CssPathSelector::*;
450
451    match selector {
452        Global => true,
453        Type(t) => node_data.get_node_type().get_path() == *t,
454        Class(c) => node_data.has_class(c.as_str()),
455        Id(id) => node_data.has_id(id.as_str()),
456        PseudoSelector(p) => {
457            match_pseudo_selector(p, html_node, expected_path_ending, is_last_content_group)
458        }
459        Attribute(a) => match_attribute_selector(a, node_data),
460        DirectChildren | Children | AdjacentSibling | GeneralSibling => false,
461    }
462}
463
464/// Matches an attribute selector (`[name]`, `[name="v"]`, `[name~="v"]`, ...) against a node.
465///
466/// Some attributes (notably `class`) are stored as multiple separate entries in
467/// `node_data.attributes()` rather than a single space-joined string. We collect
468/// every matching value and treat the matcher as "any value satisfies the op",
469/// so that `[class~="primary"]` matches a node with classes `foo primary bar`.
470fn match_attribute_selector(sel: &CssAttributeSelector, node_data: &NodeData) -> bool {
471    let name = sel.name.as_str();
472    let target = sel.value.as_ref().map(|v| v.as_str());
473
474    let check = |actual: &str| -> bool {
475        match (&sel.op, target) {
476            (AttributeMatchOp::Exists, _) => true,
477            (AttributeMatchOp::Eq, Some(t)) => actual == t,
478            (AttributeMatchOp::Includes, Some(t)) => {
479                if t.is_empty() || t.contains(char::is_whitespace) {
480                    return false;
481                }
482                actual.split_whitespace().any(|word| word == t)
483            }
484            (AttributeMatchOp::DashMatch, Some(t)) => {
485                actual == t || actual.starts_with(&alloc::format!("{}-", t))
486            }
487            (AttributeMatchOp::Prefix, Some(t)) => !t.is_empty() && actual.starts_with(t),
488            (AttributeMatchOp::Suffix, Some(t)) => !t.is_empty() && actual.ends_with(t),
489            (AttributeMatchOp::Substring, Some(t)) => !t.is_empty() && actual.contains(t),
490            // Operator with a missing value (parser should reject these โ€” be defensive).
491            (_, None) => false,
492        }
493    };
494
495    for attr in node_data.attributes().iter() {
496        if attr.name() != name {
497            continue;
498        }
499        if check(attr.value().as_str()) {
500            return true;
501        }
502    }
503
504    false
505}
506
507/// Matches a pseudo-selector (:first, :last, :nth-child, :hover, etc.) against a node.
508fn match_pseudo_selector(
509    pseudo: &CssPathPseudoSelector,
510    html_node: &CascadeInfo,
511    expected_path_ending: &Option<CssPathPseudoSelector>,
512    is_last_content_group: bool,
513) -> bool {
514    match pseudo {
515        CssPathPseudoSelector::First => match_first_child(html_node),
516        CssPathPseudoSelector::Last => match_last_child(html_node),
517        CssPathPseudoSelector::NthChild(pattern) => match_nth_child(html_node, pattern),
518        CssPathPseudoSelector::Hover => match_interactive_pseudo(
519            &CssPathPseudoSelector::Hover,
520            expected_path_ending,
521            is_last_content_group,
522        ),
523        CssPathPseudoSelector::Active => match_interactive_pseudo(
524            &CssPathPseudoSelector::Active,
525            expected_path_ending,
526            is_last_content_group,
527        ),
528        CssPathPseudoSelector::Focus => match_interactive_pseudo(
529            &CssPathPseudoSelector::Focus,
530            expected_path_ending,
531            is_last_content_group,
532        ),
533        CssPathPseudoSelector::Backdrop => match_interactive_pseudo(
534            &CssPathPseudoSelector::Backdrop,
535            expected_path_ending,
536            is_last_content_group,
537        ),
538        CssPathPseudoSelector::Dragging => match_interactive_pseudo(
539            &CssPathPseudoSelector::Dragging,
540            expected_path_ending,
541            is_last_content_group,
542        ),
543        CssPathPseudoSelector::DragOver => match_interactive_pseudo(
544            &CssPathPseudoSelector::DragOver,
545            expected_path_ending,
546            is_last_content_group,
547        ),
548        CssPathPseudoSelector::Lang(lang) => {
549            // :lang() is matched via DynamicSelector at runtime, not during CSS cascade
550            // During cascade, we just check if this is the expected ending
551            if let Some(expected) = expected_path_ending {
552                if let CssPathPseudoSelector::Lang(expected_lang) = expected {
553                    return lang == expected_lang;
554                }
555            }
556            // If not specifically looking for :lang, it doesn't match structurally
557            false
558        }
559    }
560}
561
562/// Returns true if the node is the first child of its parent.
563fn match_first_child(html_node: &CascadeInfo) -> bool {
564    html_node.index_in_parent == 0
565}
566
567/// Returns true if the node is the last child of its parent.
568fn match_last_child(html_node: &CascadeInfo) -> bool {
569    html_node.is_last_child
570}
571
572/// Matches :nth-child(n), :nth-child(even), :nth-child(odd), or :nth-child(An+B) patterns.
573fn match_nth_child(html_node: &CascadeInfo, pattern: &CssNthChildSelector) -> bool {
574    use azul_css::css::CssNthChildPattern;
575
576    // nth-child is 1-indexed, index_in_parent is 0-indexed
577    let index = html_node.index_in_parent + 1;
578
579    match pattern {
580        Number(n) => index == *n,
581        Even => index % 2 == 0,
582        Odd => index % 2 == 1,
583        Pattern(CssNthChildPattern {
584            pattern_repeat,
585            offset,
586        }) => {
587            if *pattern_repeat == 0 {
588                index == *offset
589            } else {
590                index >= *offset && ((index - offset) % pattern_repeat == 0)
591            }
592        }
593    }
594}
595
596/// Matches interactive pseudo-selectors (:hover, :active, :focus).
597/// These only apply if they appear in the last content group of the CSS path.
598fn match_interactive_pseudo(
599    pseudo: &CssPathPseudoSelector,
600    expected_path_ending: &Option<CssPathPseudoSelector>,
601    is_last_content_group: bool,
602) -> bool {
603    is_last_content_group && expected_path_ending.as_ref() == Some(pseudo)
604}