Skip to main content

tatara_ui/
theme.rs

1//! `ThemeSpec` — the Lisp-authorable, content-addressable theme.
2
3use serde::{Deserialize, Serialize};
4use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
5
6use crate::palette::{Rgb, Role, RoleMap, NORD};
7
8/// A theme is a named palette binding + sigil overrides + a BLAKE3-stable
9/// identity. Themes compose (`extends`) and snapshot to disk.
10///
11/// Author in tatara-lisp:
12///
13/// ```lisp
14/// (deftheme nord-arctic
15///   :description "the canonical tatara look"
16///   :semantic    (:info    "#81A1C1"
17///                 :success "#A3BE8C"
18///                 :warn    "#EBCB8B"
19///                 :error   "#BF616A"
20///                 :primary "#88C0D0"
21///                 :accent  "#B48EAD"
22///                 :dim     "#4C566A"))
23/// ```
24#[derive(DeriveTataraDomain, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[tatara(keyword = "deftheme")]
26pub struct ThemeSpec {
27    pub name: String,
28    #[serde(default)]
29    pub description: Option<String>,
30    /// Name of a base theme to inherit from (e.g., `"nord-arctic"`). When set,
31    /// unspecified roles fall back to the base. Resolution is the
32    /// caller's responsibility via [`ThemeRegistry::resolve`].
33    #[serde(default)]
34    pub extends: Option<String>,
35    /// Per-role hex strings. Any role not specified falls back to the base
36    /// (if `extends` is set) or to `RoleMap::default()`.
37    #[serde(default)]
38    pub semantic: SemanticOverrides,
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct SemanticOverrides {
44    #[serde(default)]
45    pub primary: Option<String>,
46    #[serde(default)]
47    pub accent: Option<String>,
48    #[serde(default)]
49    pub info: Option<String>,
50    #[serde(default)]
51    pub success: Option<String>,
52    #[serde(default)]
53    pub warn: Option<String>,
54    #[serde(default)]
55    pub error: Option<String>,
56    #[serde(default)]
57    pub dim: Option<String>,
58}
59
60impl ThemeSpec {
61    /// Built-in Nord-arctic theme — the default every tatara tool starts with.
62    pub fn nord_arctic() -> Self {
63        Self {
64            name: "nord-arctic".into(),
65            description: Some(
66                "the canonical tatara look — Nord palette, Aurora semantic roles".into(),
67            ),
68            extends: None,
69            semantic: SemanticOverrides {
70                primary: Some(NORD.nord8.as_hex()),
71                accent: Some(NORD.nord15.as_hex()),
72                info: Some(NORD.nord9.as_hex()),
73                success: Some(NORD.nord14.as_hex()),
74                warn: Some(NORD.nord13.as_hex()),
75                error: Some(NORD.nord11.as_hex()),
76                dim: Some(NORD.nord3.as_hex()),
77            },
78        }
79    }
80
81    /// Resolve the spec (no inheritance) into a concrete `RoleMap`. Any
82    /// unspecified role falls back to `RoleMap::default()`.
83    pub fn to_role_map(&self) -> RoleMap {
84        let d = RoleMap::default();
85        RoleMap {
86            primary: self
87                .semantic
88                .primary
89                .as_deref()
90                .and_then(parse_hex)
91                .unwrap_or(d.primary),
92            accent: self
93                .semantic
94                .accent
95                .as_deref()
96                .and_then(parse_hex)
97                .unwrap_or(d.accent),
98            info: self
99                .semantic
100                .info
101                .as_deref()
102                .and_then(parse_hex)
103                .unwrap_or(d.info),
104            success: self
105                .semantic
106                .success
107                .as_deref()
108                .and_then(parse_hex)
109                .unwrap_or(d.success),
110            warn: self
111                .semantic
112                .warn
113                .as_deref()
114                .and_then(parse_hex)
115                .unwrap_or(d.warn),
116            error: self
117                .semantic
118                .error
119                .as_deref()
120                .and_then(parse_hex)
121                .unwrap_or(d.error),
122            dim: self
123                .semantic
124                .dim
125                .as_deref()
126                .and_then(parse_hex)
127                .unwrap_or(d.dim),
128        }
129    }
130
131    /// Content-addressable identity — BLAKE3 of the canonical JSON.
132    /// Two specs with the same JSON produce the same id. Invariant across
133    /// renderers, machines, runs.
134    pub fn id(&self) -> ThemeId {
135        let bytes = serde_json::to_vec(self).unwrap_or_default();
136        ThemeId(hex::encode(blake3::hash(&bytes).as_bytes()))
137    }
138}
139
140impl Default for ThemeSpec {
141    fn default() -> Self {
142        Self::nord_arctic()
143    }
144}
145
146#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
147pub struct ThemeId(pub String);
148
149impl ThemeId {
150    pub fn short(&self) -> &str {
151        &self.0[..16.min(self.0.len())]
152    }
153}
154
155impl std::fmt::Display for ThemeId {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        write!(f, "theme:{}", self.short())
158    }
159}
160
161/// Parse `"#RRGGBB"` (case-insensitive) into an `Rgb`.
162fn parse_hex(s: &str) -> Option<Rgb> {
163    let t = s.trim();
164    let t = t.strip_prefix('#').unwrap_or(t);
165    if t.len() != 6 {
166        return None;
167    }
168    let r = u8::from_str_radix(&t[0..2], 16).ok()?;
169    let g = u8::from_str_radix(&t[2..4], 16).ok()?;
170    let b = u8::from_str_radix(&t[4..6], 16).ok()?;
171    Some(Rgb(r, g, b))
172}
173
174/// Tiny registry — themes in memory, resolves `extends` chains.
175#[derive(Default)]
176pub struct ThemeRegistry {
177    themes: std::collections::BTreeMap<String, ThemeSpec>,
178}
179
180impl ThemeRegistry {
181    pub fn new() -> Self {
182        let mut r = Self::default();
183        r.register(ThemeSpec::nord_arctic());
184        r
185    }
186
187    pub fn register(&mut self, spec: ThemeSpec) {
188        self.themes.insert(spec.name.clone(), spec);
189    }
190
191    pub fn get(&self, name: &str) -> Option<&ThemeSpec> {
192        self.themes.get(name)
193    }
194
195    /// Resolve `extends` — merge child overrides on top of the parent's role map.
196    pub fn resolve(&self, name: &str) -> Option<RoleMap> {
197        let spec = self.themes.get(name)?;
198        let base = match spec.extends.as_deref() {
199            Some(parent) => self.resolve(parent).unwrap_or_default(),
200            None => RoleMap::default(),
201        };
202        let overrides = spec.to_role_map();
203        Some(RoleMap {
204            primary: spec
205                .semantic
206                .primary
207                .as_ref()
208                .map_or(base.primary, |_| overrides.primary),
209            accent: spec
210                .semantic
211                .accent
212                .as_ref()
213                .map_or(base.accent, |_| overrides.accent),
214            info: spec
215                .semantic
216                .info
217                .as_ref()
218                .map_or(base.info, |_| overrides.info),
219            success: spec
220                .semantic
221                .success
222                .as_ref()
223                .map_or(base.success, |_| overrides.success),
224            warn: spec
225                .semantic
226                .warn
227                .as_ref()
228                .map_or(base.warn, |_| overrides.warn),
229            error: spec
230                .semantic
231                .error
232                .as_ref()
233                .map_or(base.error, |_| overrides.error),
234            dim: spec
235                .semantic
236                .dim
237                .as_ref()
238                .map_or(base.dim, |_| overrides.dim),
239        })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use tatara_lisp::{domain::TataraDomain, read};
247
248    #[test]
249    fn nord_arctic_resolves_to_nord_defaults() {
250        let rm = ThemeSpec::nord_arctic().to_role_map();
251        assert_eq!(rm.primary.as_hex(), "#88C0D0");
252        assert_eq!(rm.success.as_hex(), "#A3BE8C");
253    }
254
255    #[test]
256    fn id_is_content_addressed_and_stable() {
257        let a = ThemeSpec::nord_arctic();
258        let b = ThemeSpec::nord_arctic();
259        assert_eq!(a.id(), b.id());
260        assert_eq!(a.id().0.len(), 64); // BLAKE3 hex
261    }
262
263    #[test]
264    fn id_changes_when_any_role_flips() {
265        let mut a = ThemeSpec::nord_arctic();
266        a.semantic.accent = Some("#D08770".into()); // orange, not purple
267        assert_ne!(a.id(), ThemeSpec::nord_arctic().id());
268    }
269
270    #[test]
271    fn lisp_round_trip() {
272        let src = r##"(deftheme
273          :name        "warm"
274          :description "swap purple accent for orange"
275          :extends     "nord-arctic"
276          :semantic    (:accent "#D08770"))"##;
277        let forms = read(src).unwrap();
278        let t = ThemeSpec::compile_from_sexp(&forms[0]).unwrap();
279        assert_eq!(t.name, "warm");
280        assert_eq!(t.extends.as_deref(), Some("nord-arctic"));
281        assert_eq!(t.semantic.accent.as_deref(), Some("#D08770"));
282    }
283
284    #[test]
285    fn registry_resolves_extends_chain() {
286        let mut reg = ThemeRegistry::new();
287        reg.register(ThemeSpec {
288            name: "warm".into(),
289            description: None,
290            extends: Some("nord-arctic".into()),
291            semantic: SemanticOverrides {
292                accent: Some("#D08770".into()),
293                ..Default::default()
294            },
295        });
296        let rm = reg.resolve("warm").unwrap();
297        // accent overridden
298        assert_eq!(rm.accent.as_hex(), "#D08770");
299        // primary inherits from nord-arctic
300        assert_eq!(rm.primary.as_hex(), "#88C0D0");
301    }
302
303    #[test]
304    fn parse_hex_accepts_with_and_without_prefix() {
305        assert_eq!(parse_hex("#88C0D0"), Some(Rgb(0x88, 0xC0, 0xD0)));
306        assert_eq!(parse_hex("88c0d0"), Some(Rgb(0x88, 0xC0, 0xD0)));
307        assert_eq!(parse_hex("not-hex"), None);
308    }
309}