1use serde::{Deserialize, Serialize};
4use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
5
6use crate::palette::{Rgb, Role, RoleMap, NORD};
7
8#[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 #[serde(default)]
34 pub extends: Option<String>,
35 #[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 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 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 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
161fn 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#[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 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); }
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()); 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 assert_eq!(rm.accent.as_hex(), "#D08770");
299 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}