1use tokenburn_core::{Rgb, THEME_COUNT, THEME_NAMES, Tool, find_theme, theme_by_index};
8
9pub fn css(index: usize) -> String {
11 let t = theme_by_index(index.min(THEME_COUNT - 1));
12 let mut vars: Vec<(String, Rgb)> = vec![
13 ("bg".into(), t.background),
14 ("panel".into(), t.surface),
15 ("line".into(), t.border),
16 ("sel".into(), t.selection),
17 ("fg".into(), t.text_primary),
18 ("fg2".into(), t.text_secondary),
19 ("dim".into(), t.text_muted),
20 ("accent".into(), t.accent),
21 ("ok".into(), t.success),
22 ("warn".into(), t.warning),
23 ("err".into(), t.error),
24 ];
25 for (i, c) in t.series.iter().enumerate() {
26 vars.push((format!("s{i}"), *c));
27 }
28 let body: String = vars
29 .iter()
30 .map(|(k, c)| format!("--{k}:{};", c.hex()))
31 .collect();
32 format!(
33 ":root{{color-scheme:{};{body}}}",
34 if t.is_dark { "dark" } else { "light" }
35 )
36}
37
38pub fn series_var(tool: &str) -> String {
40 Tool::ALL
41 .iter()
42 .position(|t| t.as_str() == tool)
43 .map_or_else(
44 || "var(--accent)".to_string(),
45 |i| format!("var(--s{})", i % 8),
46 )
47}
48
49pub fn resolve(requested: Option<&str>, saved: usize) -> usize {
51 requested.and_then(find_theme).unwrap_or(saved)
52}
53
54pub fn names() -> &'static [&'static str] {
56 THEME_NAMES
57}
58
59pub fn catalogue() -> serde_json::Value {
61 (0..THEME_COUNT)
62 .map(|i| {
63 let t = theme_by_index(i);
64 serde_json::json!({
65 "name": THEME_NAMES[i],
66 "dark": t.is_dark,
67 "background": t.background.hex(),
68 "surface": t.surface.hex(),
69 "border": t.border.hex(),
70 "selection": t.selection.hex(),
71 "text": t.text_primary.hex(),
72 "text_secondary": t.text_secondary.hex(),
73 "text_muted": t.text_muted.hex(),
74 "accent": t.accent.hex(),
75 "success": t.success.hex(),
76 "warning": t.warning.hex(),
77 "error": t.error.hex(),
78 "series": t.series.iter().map(|c| c.hex()).collect::<Vec<_>>(),
79 })
80 })
81 .collect()
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn every_theme_yields_a_complete_rule() {
90 for i in 0..THEME_COUNT {
91 let c = css(i);
92 for v in [
93 "--bg:",
94 "--panel:",
95 "--line:",
96 "--fg:",
97 "--accent:",
98 "--ok:",
99 "--warn:",
100 "--err:",
101 "--s0:",
102 "--s7:",
103 ] {
104 assert!(c.contains(v), "theme {i} lacks {v}");
105 }
106 assert!(c.starts_with(":root{") && c.ends_with('}'));
107 }
108 }
109
110 #[test]
111 fn css_is_safe_to_inline_in_a_style_element() {
112 for i in 0..THEME_COUNT {
115 let c = css(i);
116 assert!(
117 c.chars()
118 .all(|ch| ch.is_ascii_alphanumeric() || "#:;{}-,. ".contains(ch)),
119 "theme {i}: unexpected characters in {c}"
120 );
121 }
122 }
123
124 #[test]
125 fn dark_and_light_themes_set_the_matching_color_scheme() {
126 assert!(css(0).contains("color-scheme:dark"));
127 let light = (0..THEME_COUNT)
128 .find(|i| !theme_by_index(*i).is_dark)
129 .unwrap();
130 assert!(css(light).contains("color-scheme:light"));
131 }
132
133 #[test]
134 fn out_of_range_index_is_clamped() {
135 assert_eq!(css(usize::MAX), css(THEME_COUNT - 1));
136 }
137
138 #[test]
139 fn requested_theme_wins_only_when_valid() {
140 assert_eq!(resolve(Some("nord"), 3), find_theme("Nord").unwrap());
141 assert_eq!(
142 resolve(Some("not a theme"), 3),
143 3,
144 "unknown names keep the saved theme"
145 );
146 assert_eq!(resolve(None, 7), 7);
147 }
148
149 #[test]
150 fn series_vars_are_distinct_per_tool() {
151 let vars: Vec<String> = Tool::ALL.iter().map(|t| series_var(t.as_str())).collect();
152 for (i, v) in vars.iter().enumerate() {
153 assert!(!vars[..i].contains(v));
154 }
155 assert_eq!(series_var("mystery"), "var(--accent)");
156 }
157
158 #[test]
159 fn catalogue_lists_every_theme() {
160 let c = catalogue();
161 assert_eq!(c.as_array().unwrap().len(), THEME_COUNT);
162 assert_eq!(c[0]["name"], "Default");
163 assert_eq!(c[0]["series"].as_array().unwrap().len(), 8);
164 }
165}