1use 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
18const DEFAULT_THEME: &str = "monochrome";
20
21#[derive(Debug, Clone)]
23pub struct Resolved {
24 pub theme: Option<Theme>,
26 pub diagnostics: Vec<Diagnostic>,
28}
29
30#[derive(Debug, Clone)]
34pub struct ThemeRegistry {
35 sources: BTreeMap<String, ThemeSource>,
36 diagnostics: Vec<Diagnostic>,
37}
38
39impl ThemeRegistry {
40 #[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 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 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 #[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 #[must_use]
84 pub fn diagnostics(&self) -> &[Diagnostic] {
85 &self.diagnostics
86 }
87
88 #[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 #[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(¤t) {
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
210const MAX_TOKEN_CHAIN: usize = 64;
213
214fn 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
282const REQUIRED_MOTION: [&str; 8] =
284 ["pulse-period", "flash", "cursor-blink", "step", "slide", "enter", "spinner", "shimmer"];
285
286const 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}