1use serde::Deserialize;
6
7#[derive(Debug, Clone, PartialEq, Deserialize)]
8#[serde(default)]
9pub struct Theme {
10 pub active: String,
11 pub blocked: String,
12 pub idle: String,
13 pub ink: String,
14 pub muted: String,
15 pub faint: String,
16 pub accent: String,
17 pub band: String,
18}
19
20impl Default for Theme {
21 fn default() -> Self {
22 preset("tokyo-night").expect("built-in preset")
25 }
26}
27
28#[derive(Debug, Clone, Default, Deserialize)]
31#[serde(default)]
32pub struct ThemeConfig {
33 pub preset: Option<String>,
34 pub active: Option<String>,
35 pub blocked: Option<String>,
36 pub idle: Option<String>,
37 pub ink: Option<String>,
38 pub muted: Option<String>,
39 pub faint: Option<String>,
40 pub accent: Option<String>,
41 pub band: Option<String>,
42}
43
44pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
49 let s = s.strip_prefix('#').unwrap_or(s);
50 if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
54 return None;
55 }
56 Some([
57 u8::from_str_radix(&s[0..2], 16).ok()?,
58 u8::from_str_radix(&s[2..4], 16).ok()?,
59 u8::from_str_radix(&s[4..6], 16).ok()?,
60 ])
61}
62
63impl ThemeConfig {
64 pub fn resolve(&self) -> (Theme, Option<String>) {
69 let (mut base, preset_warn) = match &self.preset {
70 None => (Theme::default(), None),
71 Some(n) => match preset(n) {
72 Some(t) => (t, None),
73 None => (
74 Theme::default(),
75 Some(format!("unknown theme '{n}' — using default")),
76 ),
77 },
78 };
79
80 let mut bad: Vec<&'static str> = Vec::new();
81 let mut apply = |slot: &mut String, name: &'static str, value: &Option<String>| {
82 if let Some(c) = value {
83 if parse_hex(c).is_some() {
84 *slot = c.clone();
85 } else {
86 bad.push(name);
87 }
88 }
89 };
90 apply(&mut base.active, "active", &self.active);
91 apply(&mut base.blocked, "blocked", &self.blocked);
92 apply(&mut base.idle, "idle", &self.idle);
93 apply(&mut base.ink, "ink", &self.ink);
94 apply(&mut base.muted, "muted", &self.muted);
95 apply(&mut base.faint, "faint", &self.faint);
96 apply(&mut base.accent, "accent", &self.accent);
97 apply(&mut base.band, "band", &self.band);
98
99 let color_warn = (!bad.is_empty())
100 .then(|| format!("ignoring invalid theme color(s): {}", bad.join(", ")));
101
102 let warn = match (preset_warn, color_warn) {
104 (Some(p), Some(c)) => Some(format!("{p}; {c}")),
105 (Some(w), None) | (None, Some(w)) => Some(w),
106 (None, None) => None,
107 };
108 (base, warn)
109 }
110}
111
112pub fn preset(name: &str) -> Option<Theme> {
114 Some(match name {
115 "default" => Theme::default(),
116 "tokyo-night" => Theme {
117 active: "#e0af68".into(),
118 blocked: "#f7768e".into(),
119 idle: "#9ece6a".into(),
120 ink: "#c0caf5".into(),
121 muted: "#787c99".into(),
122 faint: "#565f89".into(),
123 accent: "#7aa2f7".into(),
124 band: "#292e42".into(),
125 },
126 "catppuccin" => Theme {
127 active: "#f9e2af".into(),
128 blocked: "#f38ba8".into(),
129 idle: "#a6e3a1".into(),
130 ink: "#cdd6f4".into(),
131 muted: "#a6adc8".into(),
132 faint: "#6c7086".into(),
133 accent: "#89b4fa".into(),
134 band: "#313244".into(),
135 },
136 "gruvbox" => Theme {
137 active: "#d79921".into(),
138 blocked: "#cc241d".into(),
139 idle: "#98971a".into(),
140 ink: "#ebdbb2".into(),
141 muted: "#a89984".into(),
142 faint: "#665c54".into(),
143 accent: "#458588".into(),
144 band: "#3c3836".into(),
145 },
146 "nord" => Theme {
147 active: "#ebcb8b".into(),
148 blocked: "#bf616a".into(),
149 idle: "#a3be8c".into(),
150 ink: "#eceff4".into(),
151 muted: "#d8dee9".into(),
152 faint: "#4c566a".into(),
153 accent: "#88c0d0".into(),
154 band: "#3b4252".into(),
155 },
156 "dracula" => Theme {
157 active: "#f1fa8c".into(),
158 blocked: "#ff5555".into(),
159 idle: "#50fa7b".into(),
160 ink: "#f8f8f2".into(),
161 muted: "#b8b8c0".into(),
162 faint: "#6272a4".into(),
163 accent: "#bd93f9".into(),
164 band: "#44475a".into(),
165 },
166 _ => return None,
167 })
168}
169
170#[cfg(feature = "ratatui")]
171mod palette {
172 use super::{parse_hex, Theme};
173 use ratatui::style::Color;
174
175 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
177 pub struct Palette {
178 pub active: Color,
179 pub blocked: Color,
180 pub idle: Color,
181 pub ink: Color,
182 pub muted: Color,
183 pub faint: Color,
184 pub accent: Color,
185 pub band: Color,
186 }
187
188 impl From<&Theme> for Palette {
189 fn from(t: &Theme) -> Self {
190 let d = Theme::default();
193 let slot = |value: &str, fallback: &str| {
194 let [r, g, b] = parse_hex(value)
195 .or_else(|| parse_hex(fallback))
196 .expect("default theme slots are valid hex");
197 Color::Rgb(r, g, b)
198 };
199 Palette {
200 active: slot(&t.active, &d.active),
201 blocked: slot(&t.blocked, &d.blocked),
202 idle: slot(&t.idle, &d.idle),
203 ink: slot(&t.ink, &d.ink),
204 muted: slot(&t.muted, &d.muted),
205 faint: slot(&t.faint, &d.faint),
206 accent: slot(&t.accent, &d.accent),
207 band: slot(&t.band, &d.band),
208 }
209 }
210 }
211
212 impl Default for Palette {
213 fn default() -> Self {
214 Palette::from(&Theme::default())
215 }
216 }
217}
218#[cfg(feature = "ratatui")]
219pub use palette::Palette;
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn preset_default_equals_theme_default() {
227 assert_eq!(preset("default"), Some(Theme::default()));
228 }
229 #[test]
230 fn default_is_tokyo_night() {
231 assert_eq!(Theme::default(), preset("tokyo-night").unwrap());
232 }
233 #[test]
234 fn preset_known_returns_palette() {
235 let t = preset("tokyo-night").expect("exists");
236 assert_eq!(t.blocked, "#f7768e");
237 assert_eq!(t.accent, "#7aa2f7");
238 }
239 #[test]
240 fn preset_unknown_is_none() {
241 assert_eq!(preset("nope"), None);
242 }
243 #[test]
244 fn all_named_presets_exist() {
245 for name in [
246 "default",
247 "tokyo-night",
248 "catppuccin",
249 "gruvbox",
250 "nord",
251 "dracula",
252 ] {
253 assert!(preset(name).is_some(), "missing {name}");
254 }
255 }
256 #[test]
257 fn resolve_no_preset_is_default() {
258 let (theme, warn) = ThemeConfig::default().resolve();
259 assert_eq!(theme, Theme::default());
260 assert!(warn.is_none());
261 }
262 #[test]
263 fn resolve_known_preset() {
264 let tc = ThemeConfig {
265 preset: Some("gruvbox".into()),
266 ..Default::default()
267 };
268 let (theme, warn) = tc.resolve();
269 assert_eq!(theme, preset("gruvbox").unwrap());
270 assert!(warn.is_none());
271 }
272 #[test]
273 fn resolve_unknown_preset_defaults_with_warning() {
274 let tc = ThemeConfig {
275 preset: Some("typo".into()),
276 ..Default::default()
277 };
278 let (theme, warn) = tc.resolve();
279 assert_eq!(theme, Theme::default());
280 assert!(warn.as_deref().unwrap().contains("typo"));
281 }
282 #[test]
283 fn resolve_preset_with_override() {
284 let tc = ThemeConfig {
285 preset: Some("gruvbox".into()),
286 blocked: Some("#ff0000".into()),
287 ..Default::default()
288 };
289 let (theme, _) = tc.resolve();
290 assert_eq!(theme.blocked, "#ff0000");
291 assert_eq!(theme.idle, preset("gruvbox").unwrap().idle);
292 }
293
294 #[test]
295 fn parse_hex_accepts_valid_with_and_without_hash() {
296 assert_eq!(parse_hex("#7ee07e"), Some([0x7e, 0xe0, 0x7e]));
297 assert_eq!(parse_hex("7ee07e"), Some([0x7e, 0xe0, 0x7e]));
298 }
299
300 #[test]
301 fn parse_hex_rejects_non_hex_and_wrong_length() {
302 assert_eq!(parse_hex("nonsense"), None);
303 assert_eq!(parse_hex("#fff"), None);
304 assert_eq!(parse_hex("#gggggg"), None);
305 }
306
307 #[test]
308 fn parse_hex_rejects_six_byte_multibyte_without_panicking() {
309 assert_eq!(parse_hex("aéaé"), None);
311 }
312
313 #[test]
314 fn resolve_ignores_invalid_color_and_warns() {
315 let tc = ThemeConfig {
316 active: Some("notacolor".into()),
317 ..Default::default()
318 };
319 let (theme, warn) = tc.resolve();
320 assert_eq!(
321 theme.active,
322 Theme::default().active,
323 "bad color keeps base"
324 );
325 let w = warn.expect("invalid color should warn");
326 assert!(
327 w.contains("active"),
328 "warning names the offending slot: {w}"
329 );
330 }
331
332 #[test]
333 fn resolve_reports_both_unknown_preset_and_bad_color() {
334 let tc = ThemeConfig {
335 preset: Some("typo".into()),
336 blocked: Some("xyz".into()),
337 ..Default::default()
338 };
339 let w = tc.resolve().1.expect("should warn");
340 assert!(w.contains("typo"), "mentions preset: {w}");
341 assert!(w.contains("blocked"), "mentions color: {w}");
342 }
343}
344
345#[cfg(all(test, feature = "ratatui"))]
346mod palette_tests {
347 use super::*;
348 use ratatui::style::Color;
349
350 #[test]
351 fn palette_from_theme_converts_hex_to_rgb() {
352 let p = Palette::from(&Theme::default());
353 assert_eq!(p.active, Color::Rgb(0xe0, 0xaf, 0x68));
355 assert_eq!(p.band, Color::Rgb(0x29, 0x2e, 0x42));
356 }
357
358 #[test]
359 fn invalid_slot_falls_back_to_default_theme_color() {
360 let t = Theme {
361 accent: "notacolor".into(),
362 ..Theme::default()
363 };
364 let p = Palette::from(&t);
365 assert_eq!(p.accent, Color::Rgb(0x7a, 0xa2, 0xf7));
366 }
367
368 #[test]
369 fn every_preset_resolves_to_a_palette() {
370 for name in [
371 "default",
372 "tokyo-night",
373 "catppuccin",
374 "gruvbox",
375 "nord",
376 "dracula",
377 ] {
378 let _ = Palette::from(&preset(name).expect("known preset"));
379 }
380 }
381}