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}