Skip to main content

qframe/theme/
registry.rs

1//! Loading theme files and resolving `extends` chains into [`Theme`]s.
2
3use std::collections::BTreeMap;
4use std::io;
5use std::path::Path;
6use std::sync::Arc;
7
8use super::cache::StyleCache;
9use super::motion::Motion;
10use super::paint::Expr;
11use super::source::{self, MotionValue, RawProps, ThemeSource};
12use super::style::{PropValue, RawProp, StyleProps};
13use super::{REQUIRED_COLORS, Theme, validate};
14use crate::assets;
15use crate::color::Rgb;
16use crate::diagnostics::{Diagnostic, Location};
17
18/// The id of the built-in default theme.
19const DEFAULT_THEME: &str = "monochrome";
20
21/// The result of resolving a theme.
22#[derive(Debug, Clone)]
23pub struct Resolved {
24    /// The theme, when it could be built.
25    pub theme: Option<Theme>,
26    /// Errors and warnings found while resolving, including readability warnings.
27    pub diagnostics: Vec<Diagnostic>,
28}
29
30/// All themes known to an application: the built-in ones plus any loaded from disk.
31///
32/// A loaded file with the same id as a built-in theme replaces it.
33#[derive(Debug, Clone)]
34pub struct ThemeRegistry {
35    sources: BTreeMap<String, ThemeSource>,
36    diagnostics: Vec<Diagnostic>,
37}
38
39impl ThemeRegistry {
40    /// A registry holding the built-in themes.
41    #[must_use]
42    pub fn builtin() -> Self {
43        let mut registry = Self { sources: BTreeMap::new(), diagnostics: Vec::new() };
44        for (id, text) in assets::THEMES {
45            registry.add_source(id, &format!("{id}.toml"), text);
46        }
47        registry
48    }
49
50    /// Adds or replaces theme `id` from TOML text. Returns whether the file was usable.
51    pub fn add_source(&mut self, id: &str, file: &str, text: &str) -> bool {
52        match source::parse(file, text, &mut self.diagnostics) {
53            Some(parsed) => {
54                self.sources.insert(id.to_owned(), parsed);
55                true
56            }
57            None => false,
58        }
59    }
60
61    /// Loads every `*.toml` file in `dir`; the file stem is the theme id.
62    ///
63    /// # Errors
64    ///
65    /// Returns the I/O error when the directory cannot be read. A file that cannot be read is
66    /// skipped and reported in the diagnostics.
67    pub fn load_dir(&mut self, dir: &Path) -> io::Result<()> {
68        let found = assets::read_toml_dir(dir)?;
69        self.diagnostics.extend(found.skipped);
70        for (id, file, text) in found.files {
71            self.add_source(&id, &file, &text);
72        }
73        Ok(())
74    }
75
76    /// `(id, display name)` of every theme, sorted by id; for a settings screen.
77    #[must_use]
78    pub fn list(&self) -> Vec<(String, String)> {
79        self.sources.iter().map(|(id, s)| (id.clone(), s.name.clone())).collect()
80    }
81
82    /// Problems found while loading files.
83    #[must_use]
84    pub fn diagnostics(&self) -> &[Diagnostic] {
85        &self.diagnostics
86    }
87
88    /// Resolves theme `id` through its `extends` chain.
89    #[must_use]
90    pub fn resolve(&self, id: &str) -> Resolved {
91        let mut diagnostics = Vec::new();
92        let theme = self.build(id, &mut diagnostics);
93        if let Some(theme) = &theme {
94            diagnostics.extend(validate::validate(theme));
95        }
96        Resolved { theme, diagnostics }
97    }
98
99    /// Resolves theme `id`, falling back to the pristine built-in default theme when `id`
100    /// cannot be built. Diagnostics explain why a fallback happened.
101    #[must_use]
102    pub fn resolve_or_default(&self, id: &str) -> (Theme, Vec<Diagnostic>) {
103        let Resolved { theme, mut diagnostics } = self.resolve(id);
104        if let Some(theme) = theme {
105            return (theme, diagnostics);
106        }
107        diagnostics.push(Diagnostic::warning(
108            None,
109            format!("theme `{id}` could not be loaded; using the built-in `{DEFAULT_THEME}` theme"),
110        ));
111        let pristine = Self::builtin()
112            .build(DEFAULT_THEME, &mut Vec::new())
113            .expect("the built-in default theme is complete; the builtin_assets test guarantees it");
114        (pristine, diagnostics)
115    }
116
117    fn chain(&self, id: &str, diagnostics: &mut Vec<Diagnostic>) -> Option<Vec<&ThemeSource>> {
118        let mut chain: Vec<&ThemeSource> = Vec::new();
119        let mut visited: Vec<&str> = Vec::new();
120        let mut current = id;
121        let mut referenced_at: Option<&Location> = None;
122        loop {
123            if visited.contains(&current) {
124                visited.push(current);
125                diagnostics.push(Diagnostic::error(
126                    referenced_at.cloned(),
127                    format!("theme `extends` forms a cycle: {}", visited.join(" -> ")),
128                ));
129                return None;
130            }
131            let Some(source) = self.sources.get(current) else {
132                diagnostics.push(Diagnostic::error(referenced_at.cloned(), format!("unknown theme `{current}`")));
133                return None;
134            };
135            visited.push(current);
136            chain.push(source);
137            match &source.extends {
138                Some((parent, location)) => {
139                    current = parent;
140                    referenced_at = Some(location);
141                }
142                None => break,
143            }
144        }
145        chain.reverse();
146        Some(chain)
147    }
148
149    fn build(&self, id: &str, diagnostics: &mut Vec<Diagnostic>) -> Option<Theme> {
150        let chain = self.chain(id, diagnostics)?;
151
152        let mut color_exprs: BTreeMap<String, (Expr, Location)> = BTreeMap::new();
153        let mut motion_values: BTreeMap<String, MotionValue> = BTreeMap::new();
154        let mut typography_raw: BTreeMap<String, RawProps> = BTreeMap::new();
155        let mut rules_raw: Vec<(&super::Selector, &RawProps)> = Vec::new();
156        let mut icon_set = None;
157        let mut icons = BTreeMap::new();
158        let mut animations = BTreeMap::new();
159        for source in &chain {
160            for (name, expr, location) in &source.colors {
161                color_exprs.insert(name.clone(), (expr.clone(), location.clone()));
162            }
163            for (name, value) in &source.motion {
164                motion_values.insert(name.clone(), *value);
165            }
166            for (role, props) in &source.typography {
167                typography_raw.entry(role.clone()).or_default().extend(props.iter().cloned());
168            }
169            rules_raw.extend(source.styles.iter().map(|(selector, props)| (selector, props)));
170            if source.icon_set.is_some() {
171                icon_set.clone_from(&source.icon_set);
172            }
173            icons.extend(source.icons.iter().map(|(k, v)| (k.clone(), v.clone())));
174            animations.extend(source.animations.iter().map(|(k, v)| (k.clone(), Arc::new(v.clone()))));
175        }
176
177        let colors = resolve_colors(&color_exprs, diagnostics);
178        let missing: Vec<&str> = REQUIRED_COLORS.iter().copied().filter(|c| !colors.contains_key(*c)).collect();
179        if !missing.is_empty() {
180            diagnostics.push(Diagnostic::error(
181                None,
182                format!("theme `{id}` is missing colour tokens: {}", missing.join(", ")),
183            ));
184            return None;
185        }
186
187        let motion = build_motion(id, &motion_values, diagnostics)?;
188        let typography =
189            typography_raw.iter().map(|(role, raw)| (role.clone(), resolve_props(raw, &colors, diagnostics))).collect();
190        let rules = rules_raw
191            .into_iter()
192            .map(|(selector, raw)| (selector.clone(), resolve_props(raw, &colors, diagnostics)))
193            .collect();
194
195        Some(Theme {
196            id: id.to_owned(),
197            name: chain.last().map(|s| s.name.clone()).unwrap_or_default(),
198            colors,
199            motion,
200            typography,
201            rules,
202            icon_set: icon_set.unwrap_or_else(|| "default".to_owned()),
203            icons,
204            animations,
205            cache: StyleCache::default(),
206        })
207    }
208}
209
210/// How many colour tokens may refer to one another in a row. Real themes use two or three; the
211/// limit keeps a generated or hostile file from exhausting the stack.
212const MAX_TOKEN_CHAIN: usize = 64;
213
214/// Resolves colour tokens that may reference each other, reporting unknown names and cycles.
215fn resolve_colors(
216    exprs: &BTreeMap<String, (Expr, Location)>,
217    diagnostics: &mut Vec<Diagnostic>,
218) -> BTreeMap<String, Rgb> {
219    let mut resolved: BTreeMap<String, Rgb> = BTreeMap::new();
220    let mut failed: Vec<String> = Vec::new();
221    for name in exprs.keys() {
222        let mut stack = Vec::new();
223        resolve_color(name, exprs, &mut resolved, &mut failed, &mut stack, diagnostics);
224    }
225    resolved
226}
227
228fn resolve_color(
229    name: &str,
230    exprs: &BTreeMap<String, (Expr, Location)>,
231    resolved: &mut BTreeMap<String, Rgb>,
232    failed: &mut Vec<String>,
233    stack: &mut Vec<String>,
234    diagnostics: &mut Vec<Diagnostic>,
235) -> Option<Rgb> {
236    if let Some(color) = resolved.get(name) {
237        return Some(*color);
238    }
239    if failed.iter().any(|f| f == name) {
240        return None;
241    }
242    let (expr, location) = exprs.get(name)?;
243    if stack.iter().any(|s| s == name) {
244        stack.push(name.to_owned());
245        diagnostics.push(Diagnostic::error(
246            Some(location.clone()),
247            format!("colour tokens reference each other in a cycle: {}", stack.join(" -> ")),
248        ));
249        failed.push(name.to_owned());
250        return None;
251    }
252    if stack.len() >= MAX_TOKEN_CHAIN {
253        diagnostics.push(Diagnostic::error(
254            Some(location.clone()),
255            format!("colour token `{name}` sits at the end of a chain longer than {MAX_TOKEN_CHAIN} tokens"),
256        ));
257        failed.push(name.to_owned());
258        return None;
259    }
260    stack.push(name.to_owned());
261    for dependency in expr.tokens() {
262        if exprs.contains_key(dependency) {
263            resolve_color(dependency, exprs, resolved, failed, stack, diagnostics);
264        }
265    }
266    stack.pop();
267    match expr.solid(resolved) {
268        Ok(color) => {
269            resolved.insert(name.to_owned(), color);
270            Some(color)
271        }
272        Err(message) => {
273            if !failed.iter().any(|f| f == name) {
274                diagnostics.push(Diagnostic::error(Some(location.clone()), format!("colour `{name}`: {message}")));
275                failed.push(name.to_owned());
276            }
277            None
278        }
279    }
280}
281
282/// Motion keys every resolved theme defines; `page` and `hover-delay` have defaults.
283const REQUIRED_MOTION: [&str; 8] =
284    ["pulse-period", "flash", "cursor-blink", "step", "slide", "enter", "spinner", "shimmer"];
285
286/// Tooltip delay for themes whose `[motion]` does not say.
287const DEFAULT_HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(450);
288
289fn build_motion(id: &str, values: &BTreeMap<String, MotionValue>, diagnostics: &mut Vec<Diagnostic>) -> Option<Motion> {
290    let duration = |key: &str| match values.get(key) {
291        Some(MotionValue::Duration(d)) => Some(*d),
292        _ => None,
293    };
294    let (
295        Some(pulse_period),
296        Some(flash),
297        Some(cursor_blink),
298        Some(step),
299        Some(MotionValue::Flag(slide)),
300        Some(enter),
301        Some(spinner),
302        Some(shimmer),
303    ) = (
304        duration("pulse-period"),
305        duration("flash"),
306        duration("cursor-blink"),
307        duration("step"),
308        values.get("slide"),
309        duration("enter"),
310        duration("spinner"),
311        duration("shimmer"),
312    )
313    else {
314        let missing: Vec<&str> = REQUIRED_MOTION.into_iter().filter(|key| !values.contains_key(*key)).collect();
315        diagnostics.push(Diagnostic::error(None, format!("theme `{id}` must define motion {}", missing.join(", "))));
316        return None;
317    };
318    if pulse_period.is_zero() || spinner.is_zero() || shimmer.is_zero() {
319        diagnostics.push(Diagnostic::error(
320            None,
321            format!("theme `{id}`: motion.pulse-period, spinner and shimmer must be longer than 0ms"),
322        ));
323        return None;
324    }
325    let page = duration("page").unwrap_or(enter * 2);
326    let hover_delay = duration("hover-delay").unwrap_or(DEFAULT_HOVER_DELAY);
327    Some(Motion { pulse_period, flash, cursor_blink, step, slide: *slide, enter, spinner, shimmer, page, hover_delay })
328}
329
330fn resolve_props(raw: &RawProps, colors: &BTreeMap<String, Rgb>, diagnostics: &mut Vec<Diagnostic>) -> StyleProps {
331    let mut props = StyleProps::default();
332    for (key, value, location) in raw {
333        let resolved = match value {
334            RawProp::Expr(expr) => match expr.resolve(colors) {
335                Ok(paint) => PropValue::Paint(paint),
336                Err(message) => {
337                    diagnostics.push(Diagnostic::error(Some(location.clone()), format!("`{key}`: {message}")));
338                    continue;
339                }
340            },
341            RawProp::Flag(flag) => PropValue::Flag(*flag),
342            RawProp::Cells(n) => PropValue::Cells(*n),
343            RawProp::Pair(v, h) => PropValue::Pair(*v, *h),
344            RawProp::Word(word) => PropValue::Word(word),
345        };
346        props.set(key, resolved);
347    }
348    props
349}
350
351#[cfg(test)]
352mod tests {
353    use super::super::{Paint, State};
354    use super::*;
355
356    fn registry_with(files: &[(&str, &str)]) -> ThemeRegistry {
357        let mut registry = ThemeRegistry::builtin();
358        for (id, text) in files {
359            registry.add_source(id, &format!("{id}.toml"), text);
360        }
361        registry
362    }
363
364    #[test]
365    fn child_overrides_parent_tokens_and_rules_use_final_tokens() {
366        let registry = registry_with(&[(
367            "child",
368            "[meta]\nname = \"Child\"\nextends = \"monochrome\"\n[colors]\naccent = \"#ff0000\"\n[style.button]\nbg = \"$accent\"\n",
369        )]);
370        let resolved = registry.resolve("child");
371        let theme = resolved.theme.expect("child resolves");
372        assert_eq!(theme.name(), "Child");
373        assert_eq!(theme.color("accent"), Some(Rgb::new(255, 0, 0)));
374        assert_eq!(
375            theme.color("canvas"),
376            ThemeRegistry::builtin().resolve("monochrome").theme.and_then(|t| t.color("canvas"))
377        );
378        assert_eq!(theme.style("button", None, &[]).paint("bg"), Some(Paint::Solid(Rgb::new(255, 0, 0))));
379    }
380
381    #[test]
382    fn word_properties_resolve_and_inherit() {
383        let registry = registry_with(&[(
384            "thin",
385            "[meta]\nname = \"Thin\"\nextends = \"monochrome\"\n[style.scrollbar]\nstyle = \"thin\"\n",
386        )]);
387        let theme = registry.resolve("thin").theme.expect("resolves");
388        assert_eq!(theme.style("scrollbar", None, &[]).word("style"), Some("thin"));
389        assert_eq!(theme.style("scrollbar", None, &[State::Hover]).word("style"), Some("thin"));
390        let monochrome = ThemeRegistry::builtin().resolve("monochrome").theme.expect("resolves");
391        assert_eq!(monochrome.style("scrollbar", None, &[]).word("style"), Some("block"));
392    }
393
394    #[test]
395    fn specificity_then_order_decides() {
396        let registry = registry_with(&[(
397            "rules",
398            r##"[meta]
399name = "Rules"
400extends = "monochrome"
401[style."probe:hover"]
402bg = "#000003"
403[style."probe.primary"]
404bg = "#000002"
405[style.probe]
406bg = "#000001"
407bold = true
408[style."probe.primary:hover"]
409fg = "#0000ff"
410"##,
411        )]);
412        let theme = registry.resolve("rules").theme.expect("resolves");
413        let paint = |variant, states: &[State]| theme.style("probe", variant, states).paint("bg");
414        assert_eq!(paint(None, &[]), Some(Paint::Solid(Rgb::new(0, 0, 1))));
415        assert_eq!(paint(Some("primary"), &[]), Some(Paint::Solid(Rgb::new(0, 0, 2))));
416        assert_eq!(paint(Some("primary"), &[State::Hover]), Some(Paint::Solid(Rgb::new(0, 0, 3))));
417        let hovered = theme.style("probe", Some("primary"), &[State::Hover]);
418        assert!(hovered.flag("bold"));
419        assert_eq!(hovered.paint("fg"), Some(Paint::Solid(Rgb::new(0, 0, 255))));
420    }
421
422    #[test]
423    fn unknown_parent_and_cycles_fail_with_diagnostics() {
424        let registry = registry_with(&[
425            ("orphan", "[meta]\nname = \"O\"\nextends = \"ghost\"\n"),
426            ("a", "[meta]\nname = \"A\"\nextends = \"b\"\n"),
427            ("b", "[meta]\nname = \"B\"\nextends = \"a\"\n"),
428        ]);
429        let orphan = registry.resolve("orphan");
430        assert!(orphan.theme.is_none());
431        assert!(orphan.diagnostics[0].message.contains("unknown theme `ghost`"));
432        let cycle = registry.resolve("a");
433        assert!(cycle.theme.is_none());
434        assert!(cycle.diagnostics[0].message.contains("a -> b -> a"));
435    }
436
437    #[test]
438    fn token_cycles_and_missing_tokens_are_reported() {
439        let registry = registry_with(&[(
440            "loop",
441            "[meta]\nname = \"L\"\nextends = \"monochrome\"\n[colors]\naccent = \"$ink\"\nink = \"mix($accent, #000, 50%)\"\n",
442        )]);
443        let resolved = registry.resolve("loop");
444        assert!(resolved.theme.is_none());
445        let text: Vec<String> = resolved.diagnostics.iter().map(ToString::to_string).collect();
446        assert!(text.iter().any(|m| m.contains("cycle")), "{text:?}");
447        assert!(text.iter().any(|m| m.contains("missing colour tokens: accent, ink")), "{text:?}");
448    }
449
450    #[test]
451    fn an_endless_token_chain_is_a_diagnostic_not_a_stack_overflow() {
452        let chain: String = (0..200).map(|i| format!("t{i} = \"$t{}\"\n", i + 1)).collect();
453        let text = format!("[meta]\nname = \"Deep\"\nextends = \"monochrome\"\n[colors]\n{chain}t200 = \"#808080\"\n");
454        let resolved = registry_with(&[("deep", &text)]).resolve("deep");
455        let text: Vec<String> = resolved.diagnostics.iter().map(ToString::to_string).collect();
456        assert!(text.iter().any(|m| m.contains("longer than 64 tokens")), "{:?}", text.first());
457    }
458
459    #[test]
460    fn missing_motion_keys_are_named() {
461        let colors: String = REQUIRED_COLORS.iter().map(|token| format!("{token} = \"#808080\"\n")).collect();
462        let text = format!("[meta]\nname = \"Still\"\n[colors]\n{colors}[motion]\nflash = \"90ms\"\nslide = true\n");
463        let resolved = registry_with(&[("still", &text)]).resolve("still");
464        assert!(resolved.theme.is_none());
465        let text: Vec<String> = resolved.diagnostics.iter().map(ToString::to_string).collect();
466        assert_eq!(
467            text,
468            vec!["error: theme `still` must define motion pulse-period, cursor-blink, step, enter, spinner, shimmer"]
469        );
470    }
471
472    #[test]
473    fn falls_back_to_pristine_default() {
474        let registry = registry_with(&[("monochrome", "[meta]\nname = \"Broken\"\n")]);
475        let (theme, diagnostics) = registry.resolve_or_default("monochrome");
476        assert_eq!(theme.name(), "Monochrome");
477        assert!(diagnostics.iter().any(|d| d.message.contains("using the built-in `monochrome` theme")));
478    }
479
480    #[test]
481    fn readability_problems_are_warnings() {
482        let registry = registry_with(&[(
483            "murky",
484            "[meta]\nname = \"Murky\"\nextends = \"monochrome\"\n[colors]\ntext = \"#222222\"\nwarning = \"$success\"\n",
485        )]);
486        let resolved = registry.resolve("murky");
487        assert!(resolved.theme.is_some());
488        let text: Vec<String> = resolved.diagnostics.iter().map(ToString::to_string).collect();
489        assert!(text.iter().any(|m| m.contains("`text` on `canvas`")), "{text:?}");
490        assert!(text.iter().any(|m| m.contains("`success` and `warning` look too similar")), "{text:?}");
491    }
492
493    #[test]
494    fn lists_themes_for_settings() {
495        let names: Vec<String> = ThemeRegistry::builtin().list().into_iter().map(|(id, _)| id).collect();
496        assert_eq!(names, vec!["amber", "iris", "monochrome", "nordic"]);
497    }
498}