Skip to main content

eui_core/
themes.rs

1//! The built-in themes, compiled in (VAPP-92; mirrors `src/themes.ts`).
2//! `neutral` is stock shadcn and the ROOT the others extend; `exponential`
3//! is generated from the app's design tokens; `playful` is the deliberately
4//! different test theme; `orange` is `exponential` with an orange primary
5//! (colours only, the brand reference). The generator embeds them already RESOLVED
6//! (`BUILTIN_THEME_JSON`), so they are parsed once, never re-flattened.
7
8use std::sync::{Arc, LazyLock};
9
10use serde_json::Value;
11
12use crate::generated::themes as g;
13use crate::theme::{try_load_theme, ResolvedTheme, ThemeIssue, ThemeOptions, ThemeRef};
14
15/// Source order = resolution order.
16pub const BUILTIN_THEME_IDS: &[&str] = g::BUILTIN_THEME_IDS;
17pub const BUILTIN_THEME_NAMES: &[&str] = g::BUILTIN_THEME_NAMES;
18pub const DEFAULT_THEME_ID: &str = g::DEFAULT_THEME_ID;
19
20static BUILTINS: LazyLock<Vec<Arc<ResolvedTheme>>> = LazyLock::new(|| {
21    g::BUILTIN_THEME_JSON
22        .iter()
23        .map(|src| Arc::new(serde_json::from_str::<ResolvedTheme>(src).expect("built-in theme JSON")))
24        .collect()
25});
26
27/// A built-in, parsed once per process; `None` for an unknown id.
28pub fn builtin_theme(id: &str) -> Option<Arc<ResolvedTheme>> {
29    BUILTINS.iter().find(|t| t.id == id).cloned()
30}
31
32/// Every built-in, in source order.
33pub fn builtin_themes() -> Vec<Arc<ResolvedTheme>> {
34    BUILTINS.clone()
35}
36
37/// The registry a host passes as `ThemeOptions::themes` to extend a built-in.
38pub fn builtin_refs() -> Vec<ThemeRef> {
39    BUILTINS.iter().cloned().map(ThemeRef::Resolved).collect()
40}
41
42/// The default theme (`exponential`).
43pub fn default_theme() -> Arc<ResolvedTheme> {
44    builtin_theme(DEFAULT_THEME_ID).expect("default theme is a built-in")
45}
46
47/// Round 4 (VAPP-103): the theme a host paints with, NEVER a failure: a
48/// built-in id (a JSON string) or a theme file over the built-ins; anything
49/// unusable (an unknown id, a bad `$schema`, an unknown token, a missing
50/// value) falls back to [`default_theme`] with the issues saying why (TS
51/// `themeOrDefault`, Swift/Kotlin `ThemeHandle.loadOrDefault`).
52pub fn theme_or_default(input: &Value) -> (Arc<ResolvedTheme>, Vec<ThemeIssue>) {
53    if let Some(id) = input.as_str() {
54        return match builtin_theme(id) {
55            Some(t) => (t, vec![]),
56            None => (default_theme(), vec![ThemeIssue { path: "theme".into(), message: format!("unknown built-in theme \"{id}\"; known: {}", BUILTIN_THEME_IDS.join("|")) }]),
57        };
58    }
59    let refs = builtin_refs();
60    match try_load_theme(input, &ThemeOptions::core(&refs)) {
61        Ok(t) => (Arc::new(t), vec![]),
62        Err(issues) => (default_theme(), issues),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    /// VAPP-106: `extends: "neutral"` and `"playful"` need the `builtin-themes` feature.
71    #[cfg(feature = "builtin-themes")]
72    #[test]
73    fn theme_or_default_never_fails() {
74        let (t, issues) = theme_or_default(&serde_json::json!("nope"));
75        assert_eq!(t.id, DEFAULT_THEME_ID);
76        assert_eq!(issues[0].path, "theme");
77        let (t, issues) = theme_or_default(&serde_json::json!({"id": "x", "name": "X", "extends": "neutral"}));
78        assert_eq!(t.id, DEFAULT_THEME_ID);
79        assert_eq!(issues[0].path, "$schema");
80        let (t, issues) = theme_or_default(&serde_json::json!({"$schema": crate::theme::THEME_SCHEMA_ID, "id": "x", "name": "X", "extends": "neutral"}));
81        assert_eq!((t.id.as_str(), issues.len()), ("x", 0));
82        assert_eq!(theme_or_default(&serde_json::json!("playful")).0.id, "playful");
83    }
84
85    #[test]
86    fn builtins_parse_in_source_order() {
87        let ids: Vec<String> = builtin_themes().iter().map(|t| t.id.clone()).collect();
88        assert_eq!(ids, BUILTIN_THEME_IDS);
89        let names: Vec<String> = builtin_themes().iter().map(|t| t.name.clone()).collect();
90        assert_eq!(names, BUILTIN_THEME_NAMES);
91        assert_eq!(default_theme().id, "exponential");
92        assert!(builtin_theme("nope").is_none());
93    }
94
95    #[test]
96    fn builtins_reserialize_to_the_embedded_json() {
97        // Equal as JSON, numbers in the JS model (`1`, never `1.0`). Byte
98        // order differs only inside recipe `when`/`style` (sorted maps).
99        for (src, theme) in g::BUILTIN_THEME_JSON.iter().zip(builtin_themes()) {
100            let out = serde_json::to_string(&*theme).unwrap();
101            let embedded: serde_json::Value = serde_json::from_str(src).unwrap();
102            let again: serde_json::Value = serde_json::from_str(&out).unwrap();
103            assert_eq!(again, embedded, "{}", theme.id);
104            assert_eq!(out.len(), src.len(), "{}", theme.id);
105        }
106    }
107}