Skip to main content

azul_core/
style.rs

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