Skip to main content

oxiui_theme/
compile.rs

1//! Compiled stylesheet for ~O(1) widget→style lookup.
2//!
3//! [`CompiledStyleSheet`] pre-buckets rules from a [`StyleSheet`] by their
4//! primary selector part (type name, class name, or id) so that resolving
5//! a widget's style only needs to examine the relevant subset of rules rather
6//! than the full rule list.
7//!
8//! The cascade result is **identical** to [`StyleSheet::compute_style`]:
9//! specificity ordering, first-matching-selector semantics, and source-order
10//! tie-breaking are all preserved.
11
12use std::collections::HashMap;
13
14use crate::stylesheet::{
15    apply_rule, selector_matches, ComputedStyle, Rule, SelectorPart, Specificity, StyleSheet,
16};
17
18// ── CompiledStyleSheet ─────────────────────────────────────────────────────────
19
20/// A compiled stylesheet: rules indexed into buckets keyed by their primary
21/// selector part so that widget→style resolution is ~O(1) for common cases.
22///
23/// Rules are stored by-index in the `rules` vec; each bucket holds a sorted
24/// list of rule indices.  Dedup is applied during lookup so a rule whose
25/// selector list covers multiple buckets is only counted once.
26///
27/// Construct via [`CompiledStyleSheet::compile`].
28pub struct CompiledStyleSheet {
29    /// All rules from the original stylesheet (clone, so no lifetime param).
30    rules: Vec<Rule>,
31    /// Indices of rules whose first `SelectorPart` is a type name.
32    type_rules: HashMap<String, Vec<usize>>,
33    /// Indices of rules whose first `SelectorPart` is a class name.
34    class_rules: HashMap<String, Vec<usize>>,
35    /// Indices of rules whose first `SelectorPart` is an id.
36    id_rules: HashMap<String, Vec<usize>>,
37    /// Indices of rules with no specific primary key (fallback).
38    universal_rules: Vec<usize>,
39    /// Generation counter — incremented when compiled from a new stylesheet.
40    pub generation: u64,
41}
42
43impl CompiledStyleSheet {
44    /// Compile a parsed [`StyleSheet`] into a [`CompiledStyleSheet`].
45    ///
46    /// `generation` is stored on the struct and used by [`StyleCache`][crate::StyleCache]
47    /// to detect when a compiled stylesheet has changed.
48    pub fn compile(sheet: &StyleSheet, generation: u64) -> Self {
49        let mut type_rules: HashMap<String, Vec<usize>> = HashMap::new();
50        let mut class_rules: HashMap<String, Vec<usize>> = HashMap::new();
51        let mut id_rules: HashMap<String, Vec<usize>> = HashMap::new();
52        let mut universal_rules: Vec<usize> = Vec::new();
53
54        for (idx, rule) in sheet.rules.iter().enumerate() {
55            // A rule may have multiple selectors.  We bucket by the first part
56            // of *each* selector so every candidate bucket gets the index, and
57            // dedup during lookup prevents double-application.
58            if rule.selectors.is_empty() {
59                universal_rules.push(idx);
60                continue;
61            }
62
63            let mut bucketed = false;
64            for selector in &rule.selectors {
65                if let Some(first_part) = selector.parts.first() {
66                    bucketed = true;
67                    match first_part {
68                        SelectorPart::Type(name) => {
69                            type_rules.entry(name.clone()).or_default().push(idx);
70                        }
71                        SelectorPart::Class(name) => {
72                            class_rules.entry(name.clone()).or_default().push(idx);
73                        }
74                        SelectorPart::Id(name) => {
75                            id_rules.entry(name.clone()).or_default().push(idx);
76                        }
77                    }
78                } else {
79                    // Selector with no parts — treat as universal.
80                    universal_rules.push(idx);
81                }
82            }
83
84            if !bucketed {
85                universal_rules.push(idx);
86            }
87        }
88
89        // Sort each bucket by source_order ascending (stable iteration order).
90        let sort_by_source = |indices: &mut Vec<usize>, rules: &[Rule]| {
91            indices.sort_by_key(|&i| rules[i].source_order);
92            indices.dedup();
93        };
94
95        for v in type_rules.values_mut() {
96            sort_by_source(v, &sheet.rules);
97        }
98        for v in class_rules.values_mut() {
99            sort_by_source(v, &sheet.rules);
100        }
101        for v in id_rules.values_mut() {
102            sort_by_source(v, &sheet.rules);
103        }
104        sort_by_source(&mut universal_rules, &sheet.rules);
105
106        Self {
107            rules: sheet.rules.clone(),
108            type_rules,
109            class_rules,
110            id_rules,
111            universal_rules,
112            generation,
113        }
114    }
115
116    /// Compute the final [`ComputedStyle`] for a widget.
117    ///
118    /// The result is semantically identical to [`StyleSheet::compute_style`]:
119    ///
120    /// 1. Candidate rules are collected from the relevant buckets (type, each
121    ///    class, id, and universal).
122    /// 2. Rule indices are deduplicated.
123    /// 3. For each unique rule, the first matching selector's specificity is used
124    ///    (mirroring the `break` in `StyleSheet::matching_rules`).
125    /// 4. Rules are sorted by `(specificity, source_order)` ascending and applied
126    ///    in that order (last-writer-wins per property).
127    pub fn compute_style(
128        &self,
129        widget_type: &str,
130        classes: &[&str],
131        id: Option<&str>,
132    ) -> ComputedStyle {
133        // 1. Collect candidate rule indices from buckets.
134        let mut candidate_indices: Vec<usize> = Vec::new();
135
136        if let Some(idxs) = self.type_rules.get(widget_type) {
137            candidate_indices.extend_from_slice(idxs);
138        }
139        for class in classes {
140            if let Some(idxs) = self.class_rules.get(*class) {
141                candidate_indices.extend_from_slice(idxs);
142            }
143        }
144        if let Some(id_str) = id {
145            if let Some(idxs) = self.id_rules.get(id_str) {
146                candidate_indices.extend_from_slice(idxs);
147            }
148        }
149        candidate_indices.extend_from_slice(&self.universal_rules);
150
151        // 2. Deduplicate while preserving order.
152        candidate_indices.sort_unstable();
153        candidate_indices.dedup();
154
155        // 3. For each candidate, run the same first-matching-selector logic as
156        //    the original `matching_rules` — if no selector matches, skip the rule.
157        let mut matches: Vec<(usize, Specificity)> = Vec::new();
158        for idx in candidate_indices {
159            let rule = &self.rules[idx];
160            for selector in &rule.selectors {
161                if selector_matches(selector, widget_type, classes, id) {
162                    matches.push((idx, selector.specificity));
163                    break; // first matching selector for this rule — same as original
164                }
165            }
166        }
167
168        // 4. Sort by (specificity, source_order) ascending; apply in order.
169        matches.sort_by(|a, b| {
170            a.1.cmp(&b.1).then(
171                self.rules[a.0]
172                    .source_order
173                    .cmp(&self.rules[b.0].source_order),
174            )
175        });
176
177        let mut result = ComputedStyle::default();
178        for (idx, _) in &matches {
179            apply_rule(&mut result, &self.rules[*idx].style);
180        }
181        result
182    }
183}
184
185// ── Tests ─────────────────────────────────────────────────────────────────────
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::stylesheet::StyleSheet;
191
192    fn compile_css(css: &str) -> (StyleSheet, CompiledStyleSheet) {
193        let sheet = StyleSheet::parse(css).stylesheet;
194        let compiled = CompiledStyleSheet::compile(&sheet, 1);
195        (sheet, compiled)
196    }
197
198    /// Helper: assert compiled and uncompiled produce the same style for all
199    /// test inputs.
200    fn check_equivalence(css: &str, inputs: &[(&str, Vec<&str>, Option<&str>)]) {
201        let (sheet, compiled) = compile_css(css);
202        for (wtype, classes, id) in inputs {
203            let expected = sheet.compute_style(wtype, classes, *id);
204            let actual = compiled.compute_style(wtype, classes, *id);
205            assert_eq!(
206                expected, actual,
207                "divergence for widget_type={wtype:?} classes={classes:?} id={id:?}"
208            );
209        }
210    }
211
212    // ── Equivalence tests ─────────────────────────────────────────────────────
213
214    #[test]
215    fn test_compiled_matches_uncompiled_simple_selector() {
216        check_equivalence(
217            ".button { color: #ff0000; }",
218            &[
219                ("button", vec!["button"], None),
220                ("label", vec!["button"], None),
221                ("button", vec![], None),
222            ],
223        );
224    }
225
226    #[test]
227    fn test_compiled_matches_uncompiled_compound_selector() {
228        check_equivalence(
229            ".button.primary { background: #0000ff; }",
230            &[
231                ("button", vec!["button", "primary"], None),
232                ("button", vec!["button"], None),
233                ("button", vec!["primary"], None),
234                ("label", vec!["button", "primary"], None),
235            ],
236        );
237    }
238
239    #[test]
240    fn test_compiled_matches_uncompiled_grouped_selector() {
241        check_equivalence(
242            "button, label { color: #000000; }",
243            &[
244                ("button", vec![], None),
245                ("label", vec![], None),
246                ("input", vec![], None),
247            ],
248        );
249    }
250
251    #[test]
252    fn test_specificity_tiebreak_preserved_post_compile() {
253        // More specific rule (#id) must win over type rule.
254        check_equivalence(
255            "button { color: #ff0000; } #submit { color: #00ff00; }",
256            &[("button", vec![], Some("submit")), ("button", vec![], None)],
257        );
258    }
259
260    /// Regression: grouped selector where widget matches both selectors of a
261    /// single rule must not apply the rule twice and must not produce wrong
262    /// specificity compared to the uncompiled path.
263    #[test]
264    fn test_compiled_matches_uncompiled_ambiguous_grouped() {
265        // Rule 0: `button, .foo { color: #ff0000 }` — specificity via `.foo`
266        //          is (0,1,0), via `button` is (0,0,1).
267        //          For widget type=button with class=foo: original picks
268        //          `button` selector first → spec=(0,0,1); colour = red.
269        // Rule 1: `button { color: #0000ff }` — spec=(0,0,1), source_order=1.
270        //          Same specificity, higher source_order → blue wins.
271        // So final colour must be blue (#0000ff), not red.
272        check_equivalence(
273            "button, .foo { color: #ff0000; } button { color: #0000ff; }",
274            &[
275                ("button", vec!["foo"], None),
276                ("button", vec![], None),
277                ("label", vec!["foo"], None),
278            ],
279        );
280    }
281
282    /// Broad cross-check loop over multiple inputs for a realistic stylesheet.
283    #[test]
284    fn test_compiled_matches_uncompiled_cross_check() {
285        let css = r#"
286            button { color: #111111; padding: 8px; }
287            .primary { background: #7aa2f7; }
288            button.primary { font-size: 14px; }
289            #cancel { color: #ff0000; }
290            label, input { font-size: 12px; }
291            .disabled { opacity: 0.5; }
292        "#;
293        let inputs: &[(&str, Vec<&str>, Option<&str>)] = &[
294            ("button", vec![], None),
295            ("button", vec!["primary"], None),
296            ("button", vec!["primary", "disabled"], None),
297            ("button", vec!["disabled"], Some("cancel")),
298            ("label", vec![], None),
299            ("input", vec!["primary"], None),
300            ("input", vec!["disabled"], None),
301            ("span", vec![], None),
302        ];
303        check_equivalence(css, inputs);
304    }
305}