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::{Even, Number, Odd, Pattern},
12    CssPath, CssPathPseudoSelector, CssPathSelector,
13};
14
15use crate::{
16    dom::NodeData,
17    id::{NodeDataContainer, NodeDataContainerRef, NodeHierarchyRef, NodeId},
18    styled_dom::NodeHierarchyItem,
19};
20
21/// Has all the necessary information about the style CSS path
22#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
23#[repr(C)]
24pub struct CascadeInfo {
25    pub index_in_parent: u32,
26    pub is_last_child: bool,
27}
28
29impl_option!(
30    CascadeInfo,
31    OptionCascadeInfo,
32    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
33);
34
35impl_vec!(
36    CascadeInfo,
37    CascadeInfoVec,
38    CascadeInfoVecDestructor,
39    CascadeInfoVecDestructorType,
40    CascadeInfoVecSlice,
41    OptionCascadeInfo
42);
43impl_vec_mut!(CascadeInfo, CascadeInfoVec);
44impl_vec_debug!(CascadeInfo, CascadeInfoVec);
45impl_vec_partialord!(CascadeInfo, CascadeInfoVec);
46impl_vec_clone!(CascadeInfo, CascadeInfoVec, CascadeInfoVecDestructor);
47impl_vec_partialeq!(CascadeInfo, CascadeInfoVec);
48
49impl CascadeInfoVec {
50    #[must_use]
51    pub fn as_container(&self) -> NodeDataContainerRef<'_, CascadeInfo> {
52        NodeDataContainerRef {
53            internal: self.as_ref(),
54        }
55    }
56}
57
58/// Returns if the style CSS path matches the DOM node (i.e. if the DOM node should be styled by
59/// that element)
60#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
61#[allow(clippy::too_many_lines)] // large but cohesive: one branch per selector kind
62#[must_use]
63pub fn matches_html_element(
64    css_path: &CssPath,
65    node_id: NodeId,
66    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
67    node_data: &NodeDataContainerRef<'_, NodeData>,
68    html_node_tree: &NodeDataContainerRef<'_, CascadeInfo>,
69    expected_path_ending: Option<CssPathPseudoSelector>,
70) -> bool {
71    use self::CssGroupSplitReason::{AdjacentSibling, Children, DirectChildren, GeneralSibling};
72
73    if css_path.selectors.is_empty() {
74        return false;
75    }
76
77    // Skip anonymous nodes - they are not part of the original DOM tree
78    // and should not participate in CSS selector matching
79    if node_data[node_id].is_anonymous() {
80        return false;
81    }
82
83    // Collect all selector groups (processed right-to-left from the CSS path).
84    let groups: Vec<(CssContentGroup<'_>, CssGroupSplitReason)> =
85        CssGroupIterator::new(css_path.selectors.as_ref()).collect();
86
87    if groups.is_empty() {
88        return false;
89    }
90
91    // The rightmost group must match the target node directly.
92    let (ref first_group, first_reason) = groups[0];
93    // groups[0] is ALWAYS the subject (rightmost) group, so it is the "last content
94    // group" that an interactive pseudo (:hover/:focus/:active) attaches to — regardless
95    // of how many ancestor groups precede it. The old `groups.len() == 1` disabled
96    // :hover on the subject of every multi-group selector (e.g. `body > div:hover`).
97    let is_last_content_group = true;
98    if !selector_group_matches(
99        first_group,
100        html_node_tree[node_id],
101        &node_data[node_id],
102        node_id,
103        expected_path_ending.as_ref(),
104        is_last_content_group,
105    ) {
106        return false;
107    }
108
109    // Navigate from the target node upward/sideways through the DOM,
110    // matching each remaining selector group with its combinator.
111    let mut current_node = node_id;
112
113    for (group_idx, (content_group, _reason)) in groups.iter().enumerate().skip(1) {
114        // The combinator comes from the PREVIOUS group's reason
115        let combinator = groups[group_idx - 1].1;
116        let is_last = group_idx == groups.len() - 1;
117
118        match combinator {
119            DirectChildren => {
120                // Parent must match directly (child combinator `>`)
121                let parent = find_non_anonymous_parent(current_node, node_hierarchy, node_data);
122                match parent {
123                    Some(p)
124                        if selector_group_matches(
125                            content_group,
126                            html_node_tree[p],
127                            &node_data[p],
128                            p,
129                            expected_path_ending.as_ref(),
130                            is_last,
131                        ) =>
132                    {
133                        current_node = p;
134                    }
135                    _ => return false,
136                }
137            }
138            Children => {
139                // Search up ancestor chain for a match (descendant combinator ` `)
140                let mut ancestor =
141                    find_non_anonymous_parent(current_node, node_hierarchy, node_data);
142                let mut found = false;
143                while let Some(anc) = ancestor {
144                    if selector_group_matches(
145                        content_group,
146                        html_node_tree[anc],
147                        &node_data[anc],
148                        anc,
149                        expected_path_ending.as_ref(),
150                        is_last,
151                    ) {
152                        current_node = anc;
153                        found = true;
154                        break;
155                    }
156                    ancestor = find_non_anonymous_parent(anc, node_hierarchy, node_data);
157                }
158                if !found {
159                    return false;
160                }
161            }
162            AdjacentSibling => {
163                // Immediate previous sibling must match (adjacent sibling `+`)
164                let sibling =
165                    find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
166                match sibling {
167                    Some(s)
168                        if selector_group_matches(
169                            content_group,
170                            html_node_tree[s],
171                            &node_data[s],
172                            s,
173                            expected_path_ending.as_ref(),
174                            is_last,
175                        ) =>
176                    {
177                        current_node = s;
178                    }
179                    _ => return false,
180                }
181            }
182            GeneralSibling => {
183                // Search previous siblings for a match (general sibling `~`)
184                let mut sibling =
185                    find_non_anonymous_prev_sibling(current_node, node_hierarchy, node_data);
186                let mut found = false;
187                while let Some(sib) = sibling {
188                    if selector_group_matches(
189                        content_group,
190                        html_node_tree[sib],
191                        &node_data[sib],
192                        sib,
193                        expected_path_ending.as_ref(),
194                        is_last,
195                    ) {
196                        current_node = sib;
197                        found = true;
198                        break;
199                    }
200                    sibling = find_non_anonymous_prev_sibling(sib, node_hierarchy, node_data);
201                }
202                if !found {
203                    return false;
204                }
205            }
206        }
207    }
208
209    true
210}
211
212/// Find the first non-anonymous parent of a node.
213fn find_non_anonymous_parent(
214    node_id: NodeId,
215    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
216    node_data: &NodeDataContainerRef<'_, NodeData>,
217) -> Option<NodeId> {
218    let mut next = node_hierarchy[node_id].parent_id();
219    while let Some(n) = next {
220        if !node_data[n].is_anonymous() {
221            return Some(n);
222        }
223        next = node_hierarchy[n].parent_id();
224    }
225    None
226}
227
228/// Find the first previous sibling of a node that the `+`/`~` combinators can target:
229/// an element, skipping anonymous boxes AND non-element (text) nodes.
230///
231/// CSS sibling combinators operate on ELEMENTS (Selectors L4 §15.2), so an intervening
232/// text node must not block `.a + .b` from reaching the preceding element. Skipping only
233/// anonymous boxes left text siblings in the way.
234fn find_non_anonymous_prev_sibling(
235    node_id: NodeId,
236    node_hierarchy: &NodeDataContainerRef<'_, NodeHierarchyItem>,
237    node_data: &NodeDataContainerRef<'_, NodeData>,
238) -> Option<NodeId> {
239    let mut next = node_hierarchy[node_id].previous_sibling_id();
240    while let Some(n) = next {
241        if !node_data[n].is_anonymous() && !node_data[n].is_text_node() {
242            return Some(n);
243        }
244        next = node_hierarchy[n].previous_sibling_id();
245    }
246    None
247}
248
249/// A CSS group is a group of css selectors in a path that specify the rule that a
250/// certain node has to match, i.e. "div.main.foo" has to match three requirements:
251///
252/// - the node has to be of type div
253/// - the node has to have the class "main"
254/// - the node has to have the class "foo"
255///
256/// If any of these requirements are not met, the CSS block is discarded.
257///
258/// The `CssGroupIterator` splits the CSS path into semantic blocks, i.e.:
259///
260/// `"body > .foo.main > #baz"` will be split into `["body", ".foo.main", "#baz"]`
261#[derive(Debug)]
262pub struct CssGroupIterator<'a> {
263    pub css_path: &'a [CssPathSelector],
264    current_idx: usize,
265    last_reason: CssGroupSplitReason,
266}
267
268#[derive(Debug, Copy, Clone, PartialEq, Eq)]
269pub enum CssGroupSplitReason {
270    /// ".foo .main" - match any children
271    Children,
272    /// ".foo > .main" - match only direct children
273    DirectChildren,
274    /// ".foo + .main" - match adjacent sibling (immediately preceding)
275    AdjacentSibling,
276    /// ".foo ~ .main" - match general sibling (any preceding sibling)
277    GeneralSibling,
278}
279
280impl<'a> CssGroupIterator<'a> {
281    #[must_use]
282    pub const fn new(css_path: &'a [CssPathSelector]) -> Self {
283        let initial_len = css_path.len();
284        Self {
285            css_path,
286            current_idx: initial_len,
287            last_reason: CssGroupSplitReason::Children,
288        }
289    }
290}
291
292impl<'a> Iterator for CssGroupIterator<'a> {
293    type Item = (CssContentGroup<'a>, CssGroupSplitReason);
294
295    fn next(&mut self) -> Option<(CssContentGroup<'a>, CssGroupSplitReason)> {
296        use self::CssPathSelector::{AdjacentSibling, Children, DirectChildren, GeneralSibling};
297
298        let mut new_idx = self.current_idx;
299
300        if new_idx == 0 {
301            return None;
302        }
303
304        let mut current_path = Vec::new();
305
306        while new_idx != 0 {
307            match self.css_path.get(new_idx - 1)? {
308                Children => {
309                    self.last_reason = CssGroupSplitReason::Children;
310                    break;
311                }
312                DirectChildren => {
313                    self.last_reason = CssGroupSplitReason::DirectChildren;
314                    break;
315                }
316                AdjacentSibling => {
317                    self.last_reason = CssGroupSplitReason::AdjacentSibling;
318                    break;
319                }
320                GeneralSibling => {
321                    self.last_reason = CssGroupSplitReason::GeneralSibling;
322                    break;
323                }
324                other => current_path.push(other),
325            }
326            new_idx -= 1;
327        }
328
329        // NOTE: Order inside of a ContentGroup is not important
330        // for matching elements, only important for testing
331        #[cfg(test)]
332        current_path.reverse();
333
334        if new_idx == 0 {
335            if current_path.is_empty() {
336                None
337            } else {
338                // Last element of path
339                self.current_idx = 0;
340                Some((current_path, self.last_reason))
341            }
342        } else {
343            // skip the "Children | DirectChildren" element itself
344            self.current_idx = new_idx - 1;
345            Some((current_path, self.last_reason))
346        }
347    }
348}
349
350#[must_use]
351pub fn construct_html_cascade_tree(
352    node_hierarchy: &NodeHierarchyRef<'_>,
353    node_depths_sorted: &[(usize, NodeId)],
354    node_data: &NodeDataContainerRef<'_, NodeData>,
355) -> NodeDataContainer<CascadeInfo> {
356    let mut nodes = (0..node_hierarchy.len())
357        .map(|_| CascadeInfo {
358            index_in_parent: 0,
359            is_last_child: false,
360        })
361        .collect::<Vec<_>>();
362
363    for (_depth, parent_id) in node_depths_sorted {
364        // Per CSS Selectors Level 4 §13: "Standalone text and other non-element
365        // nodes are not counted when calculating the position of an element in
366        // the list of children of its parent."
367        //
368        // We count only element siblings when computing index_in_parent.
369        let element_index_in_parent = parent_id
370            .preceding_siblings(node_hierarchy)
371            .filter(|sib_id| !node_data[*sib_id].is_text_node())
372            .count();
373
374        let parent_html_matcher = CascadeInfo {
375            index_in_parent: u32::try_from(element_index_in_parent.saturating_sub(1))
376                .unwrap_or(u32::MAX),
377            // Necessary for :last selectors — find last element sibling
378            is_last_child: {
379                let mut is_last_element = true;
380                let mut next = node_hierarchy[*parent_id].next_sibling;
381                while let Some(sib_id) = next {
382                    if !node_data[sib_id].is_text_node() {
383                        is_last_element = false;
384                        break;
385                    }
386                    next = node_hierarchy[sib_id].next_sibling;
387                }
388                is_last_element
389            },
390        };
391
392        nodes[parent_id.index()] = parent_html_matcher;
393
394        // Count only element children for index_in_parent
395        let mut element_idx: u32 = 0;
396        for child_id in parent_id.children(node_hierarchy) {
397            let is_text = node_data[child_id].is_text_node();
398
399            // Find whether this is the last element child (skip trailing text nodes)
400            let is_last_element_child = if is_text {
401                false
402            } else {
403                let mut is_last = true;
404                let mut next = node_hierarchy[child_id].next_sibling;
405                while let Some(sib_id) = next {
406                    if !node_data[sib_id].is_text_node() {
407                        is_last = false;
408                        break;
409                    }
410                    next = node_hierarchy[sib_id].next_sibling;
411                }
412                is_last
413            };
414
415            let child_html_matcher = CascadeInfo {
416                index_in_parent: element_idx,
417                is_last_child: is_last_element_child,
418            };
419
420            nodes[child_id.index()] = child_html_matcher;
421
422            if !is_text {
423                element_idx += 1;
424            }
425        }
426    }
427
428    NodeDataContainer { internal: nodes }
429}
430
431/// Checks whether the last selector in `path` matches the given pseudo-selector `target`.
432///
433/// Known limitation: this only inspects the final selector in the path, so compound
434/// selectors like `div:hover:first-child` may not be filtered correctly when `target`
435/// is `None` — only the very last pseudo-selector is tested.
436#[inline]
437#[must_use]
438pub fn rule_ends_with(path: &CssPath, target: Option<CssPathPseudoSelector>) -> bool {
439    // Helper to check if a pseudo-selector is "interactive" (requires user interaction state)
440    // vs "structural" (based on DOM structure only)
441    const fn is_interactive_pseudo(p: &CssPathPseudoSelector) -> bool {
442        matches!(
443            p,
444            CssPathPseudoSelector::Hover
445                | CssPathPseudoSelector::Active
446                | CssPathPseudoSelector::Focus
447                | CssPathPseudoSelector::SeatFocus
448                | CssPathPseudoSelector::Backdrop
449                | CssPathPseudoSelector::Dragging
450                | CssPathPseudoSelector::DragOver
451                | CssPathPseudoSelector::Placeholder
452        )
453    }
454
455    let Some(last) = path.selectors.as_ref().last() else {
456        return false;
457    };
458    target.map_or_else(
459        || match last {
460            // Only reject interactive pseudo-selectors (hover, active, focus).
461            // Structural pseudo-selectors (nth-child, first, last) should be allowed.
462            CssPathSelector::PseudoSelector(p) => !is_interactive_pseudo(p),
463            _ => true,
464        },
465        |s| matches!(last, CssPathSelector::PseudoSelector(q) if *q == s),
466    )
467}
468
469/// Matches a single group of CSS selectors against a DOM node.
470///
471/// Returns true if all selectors in the group match the given node.
472/// Combinator selectors (>, +, ~, space) should not appear in the group.
473fn selector_group_matches(
474    selectors: &[&CssPathSelector],
475    html_node: CascadeInfo,
476    node_data: &NodeData,
477    node_id: NodeId,
478    expected_path_ending: Option<&CssPathPseudoSelector>,
479    is_last_content_group: bool,
480) -> bool {
481    // Inline-style detection for the `Global` arm: a bare-declaration
482    // `with_css` rule is scoped to EXACTLY its owner node when the scope is
483    // pushed (`push_front_scope_for` collapses the range to `[owner, owner]`
484    // for INLINE-priority bare `*` wrappers). Such a rule is the author
485    // addressing THIS node directly — it must style a text node too.
486    let node_scoped_to_self = selectors.iter().any(|s| {
487        matches!(s, CssPathSelector::Root(r)
488            if r.start == r.end && r.start == node_id.index())
489    });
490    selectors.iter().all(|selector| {
491        match_single_selector(
492            selector,
493            html_node,
494            node_data,
495            node_id,
496            expected_path_ending,
497            is_last_content_group,
498            node_scoped_to_self,
499        )
500    })
501}
502
503/// Matches a single CSS selector against a DOM node.
504fn match_single_selector(
505    selector: &CssPathSelector,
506    html_node: CascadeInfo,
507    node_data: &NodeData,
508    node_id: NodeId,
509    expected_path_ending: Option<&CssPathPseudoSelector>,
510    is_last_content_group: bool,
511    node_scoped_to_self: bool,
512) -> bool {
513    use self::CssPathSelector::{
514        AdjacentSibling, Attribute, Children, Class, DirectChildren, GeneralSibling, Global, Id,
515        PseudoSelector, Root, Type,
516    };
517
518    match selector {
519        // Per CSS, `*` matches ELEMENTS - never text nodes: letting a
520        // stylesheet's universal selector hit text nodes made
521        // `* { color: #666 }` overwrite the color a text child had just
522        // inherited from its `p { color: red }` parent. The one exception is
523        // a rule scoped to EXACTLY this node (`node_scoped_to_self`) — that
524        // is a bare-declaration `with_css` ON the text node itself, i.e.
525        // inline-style semantics: `create_text_do_not_use_without_block_level_wrapper("x").with_css("color: white")`
526        // must apply. Subtree-scoped and unscoped `*` rules keep refusing
527        // text nodes.
528        Global => !node_data.is_text_node() || node_scoped_to_self,
529        // `Root(range)` (scope marker, #47): matches any node WITHIN the subtree
530        // range `[start, end]`. The range is chosen when the scope is pushed
531        // (`CssPath::push_front_scope`):
532        //  - a bare-decl `with_css` rule (`* { … }`) is scoped node-only (`[start,
533        //    start]`) → inline-style semantics: it applies to the OWNER only, so a
534        //    non-root `background` can't leak to descendants/siblings (#47 leak fix).
535        //  - a component rule with a real selector (`.menu-item`, from
536        //    `add_component_css`) is scoped to the whole subtree (`[start, end]`) so
537        //    its selector matches descendants of the owner (a menu container styling
538        //    its `.menu-item` children). Compounded with the rest of the path,
539        //    `[Root(range), Class(x)]` means "a node in range that also matches `.x`".
540        Root(range) => range.contains(node_id.index()),
541        Type(t) => node_data.get_node_type().get_path() == *t,
542        Class(c) => node_data.has_class(c.as_str()),
543        Id(id) => node_data.has_id(id.as_str()),
544        // `:root` matches the document root element (NodeId::ZERO, the topmost
545        // element). Handled here rather than in `match_pseudo_selector` because it
546        // needs `node_id`. Equivalent to `html` but with pseudo-class specificity.
547        PseudoSelector(CssPathPseudoSelector::Root) => node_id.index() == 0,
548        PseudoSelector(p) => {
549            match_pseudo_selector(p, html_node, expected_path_ending, is_last_content_group)
550        }
551        Attribute(a) => match_attribute_selector(a, node_data),
552        DirectChildren | Children | AdjacentSibling | GeneralSibling => false,
553    }
554}
555
556/// Matches an attribute selector (`[name]`, `[name="v"]`, `[name~="v"]`, ...) against a node.
557///
558/// Some attributes (notably `class`) are stored as multiple separate entries in
559/// `node_data.attributes()` rather than a single space-joined string. We collect
560/// every matching value and treat the matcher as "any value satisfies the op",
561/// so that `[class~="primary"]` matches a node with classes `foo primary bar`.
562fn match_attribute_selector(sel: &CssAttributeSelector, node_data: &NodeData) -> bool {
563    let name = sel.name.as_str();
564    let target = sel.value.as_ref().map(azul_css::AzString::as_str);
565
566    let check = |actual: &str| -> bool {
567        match (&sel.op, target) {
568            (AttributeMatchOp::Exists, _) => true,
569            (AttributeMatchOp::Eq, Some(t)) => actual == t,
570            (AttributeMatchOp::Includes, Some(t)) => {
571                if t.is_empty() || t.contains(char::is_whitespace) {
572                    return false;
573                }
574                actual.split_whitespace().any(|word| word == t)
575            }
576            (AttributeMatchOp::DashMatch, Some(t)) => {
577                actual == t || actual.starts_with(&alloc::format!("{t}-"))
578            }
579            (AttributeMatchOp::Prefix, Some(t)) => !t.is_empty() && actual.starts_with(t),
580            (AttributeMatchOp::Suffix, Some(t)) => !t.is_empty() && actual.ends_with(t),
581            (AttributeMatchOp::Substring, Some(t)) => !t.is_empty() && actual.contains(t),
582            // Operator with a missing value (parser should reject these — be defensive).
583            (_, None) => false,
584        }
585    };
586
587    for attr in node_data.attributes() {
588        if attr.name() != name {
589            continue;
590        }
591        if check(attr.value().as_str()) {
592            return true;
593        }
594    }
595
596    false
597}
598
599/// Matches a pseudo-selector (:first, :last, :nth-child, :hover, etc.) against a node.
600fn match_pseudo_selector(
601    pseudo: &CssPathPseudoSelector,
602    html_node: CascadeInfo,
603    expected_path_ending: Option<&CssPathPseudoSelector>,
604    is_last_content_group: bool,
605) -> bool {
606    match pseudo {
607        CssPathPseudoSelector::First => match_first_child(html_node),
608        CssPathPseudoSelector::Last => match_last_child(html_node),
609        CssPathPseudoSelector::NthChild(pattern) => match_nth_child(html_node, pattern),
610        CssPathPseudoSelector::Hover => match_interactive_pseudo(
611            &CssPathPseudoSelector::Hover,
612            expected_path_ending,
613            is_last_content_group,
614        ),
615        CssPathPseudoSelector::Active => match_interactive_pseudo(
616            &CssPathPseudoSelector::Active,
617            expected_path_ending,
618            is_last_content_group,
619        ),
620        CssPathPseudoSelector::Focus => match_interactive_pseudo(
621            &CssPathPseudoSelector::Focus,
622            expected_path_ending,
623            is_last_content_group,
624        ),
625        CssPathPseudoSelector::SeatFocus => match_interactive_pseudo(
626            &CssPathPseudoSelector::SeatFocus,
627            expected_path_ending,
628            is_last_content_group,
629        ),
630        CssPathPseudoSelector::Backdrop => match_interactive_pseudo(
631            &CssPathPseudoSelector::Backdrop,
632            expected_path_ending,
633            is_last_content_group,
634        ),
635        CssPathPseudoSelector::Dragging => match_interactive_pseudo(
636            &CssPathPseudoSelector::Dragging,
637            expected_path_ending,
638            is_last_content_group,
639        ),
640        CssPathPseudoSelector::DragOver => match_interactive_pseudo(
641            &CssPathPseudoSelector::DragOver,
642            expected_path_ending,
643            is_last_content_group,
644        ),
645        CssPathPseudoSelector::Lang(lang) => {
646            // :lang() is matched via DynamicSelector at runtime, not during CSS cascade
647            // During cascade, we just check if this is the expected ending
648            if let Some(CssPathPseudoSelector::Lang(expected_lang)) = expected_path_ending {
649                return lang == expected_lang;
650            }
651            // If not specifically looking for :lang, it doesn't match structurally
652            false
653        }
654        // `::placeholder` styles PAINTED GLYPHS, not the node, so it can only
655        // match the dedicated resolve the engine does for the prompt - the
656        // same "expected ending" gate the interactive pseudos use.
657        CssPathPseudoSelector::Placeholder => match_interactive_pseudo(
658            &CssPathPseudoSelector::Placeholder,
659            expected_path_ending,
660            is_last_content_group,
661        ),
662        // `:root` is matched in `match_single_selector` (it needs `node_id`), so it
663        // never reaches here — return false defensively.
664        CssPathPseudoSelector::Root => false,
665    }
666}
667
668/// Returns true if the node is the first child of its parent.
669const fn match_first_child(html_node: CascadeInfo) -> bool {
670    html_node.index_in_parent == 0
671}
672
673/// Returns true if the node is the last child of its parent.
674const fn match_last_child(html_node: CascadeInfo) -> bool {
675    html_node.is_last_child
676}
677
678/// Matches :nth-child(n), :nth-child(even), :nth-child(odd), or :nth-child(An+B) patterns.
679fn match_nth_child(html_node: CascadeInfo, pattern: &CssNthChildSelector) -> bool {
680    use azul_css::css::CssNthChildPattern;
681
682    // nth-child is 1-indexed, index_in_parent is 0-indexed
683    let index = html_node.index_in_parent + 1;
684
685    match pattern {
686        Number(n) => index == *n,
687        Even => index.is_multiple_of(2),
688        Odd => index % 2 == 1,
689        Pattern(CssNthChildPattern {
690            pattern_repeat,
691            offset,
692        }) => {
693            if *pattern_repeat == 0 {
694                index == *offset
695            } else {
696                index >= *offset && (index - offset).is_multiple_of(*pattern_repeat)
697            }
698        }
699    }
700}
701
702/// Matches interactive pseudo-selectors (:hover, :active, :focus).
703/// These only apply if they appear in the last content group of the CSS path.
704fn match_interactive_pseudo(
705    pseudo: &CssPathPseudoSelector,
706    expected_path_ending: Option<&CssPathPseudoSelector>,
707    is_last_content_group: bool,
708) -> bool {
709    is_last_content_group && expected_path_ending == Some(pseudo)
710}
711
712#[cfg(test)]
713#[path = "style_test.rs"]
714mod style_test;