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
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum Density {
50 Compact,
51 #[default]
52 Comfortable,
53 Spacious,
54}
55
56impl Density {
57 pub fn parse(s: &str) -> Option<Self> {
60 match s.trim().to_ascii_lowercase().as_str() {
61 "compact" => Some(Density::Compact),
62 "comfortable" => Some(Density::Comfortable),
63 "spacious" => Some(Density::Spacious),
64 _ => None,
65 }
66 }
67
68 pub fn blank_lines(self) -> usize {
70 match self {
71 Density::Compact => 0,
72 Density::Comfortable | Density::Spacious => 1,
73 }
74 }
75
76 pub fn gutter(self) -> usize {
78 match self {
79 Density::Compact => 0,
80 Density::Comfortable => 2,
81 Density::Spacious => 4,
82 }
83 }
84}
85
86pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
91 let s = s.strip_prefix('#').unwrap_or(s);
92 if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
96 return None;
97 }
98 Some([
99 u8::from_str_radix(&s[0..2], 16).ok()?,
100 u8::from_str_radix(&s[2..4], 16).ok()?,
101 u8::from_str_radix(&s[4..6], 16).ok()?,
102 ])
103}
104
105impl ThemeConfig {
106 pub fn resolve(&self) -> (Theme, Option<String>) {
111 let (mut base, preset_warn) = match &self.preset {
112 None => (Theme::default(), None),
113 Some(n) => match preset(n) {
114 Some(t) => (t, None),
115 None => (
116 Theme::default(),
117 Some(format!("unknown theme '{n}' — using default")),
118 ),
119 },
120 };
121
122 let mut bad: Vec<&'static str> = Vec::new();
123 let mut apply = |slot: &mut String, name: &'static str, value: &Option<String>| {
124 if let Some(c) = value {
125 if parse_hex(c).is_some() {
126 *slot = c.clone();
127 } else {
128 bad.push(name);
129 }
130 }
131 };
132 apply(&mut base.active, "active", &self.active);
133 apply(&mut base.blocked, "blocked", &self.blocked);
134 apply(&mut base.idle, "idle", &self.idle);
135 apply(&mut base.ink, "ink", &self.ink);
136 apply(&mut base.muted, "muted", &self.muted);
137 apply(&mut base.faint, "faint", &self.faint);
138 apply(&mut base.accent, "accent", &self.accent);
139 apply(&mut base.band, "band", &self.band);
140
141 let color_warn = (!bad.is_empty())
142 .then(|| format!("ignoring invalid theme color(s): {}", bad.join(", ")));
143
144 let warn = match (preset_warn, color_warn) {
146 (Some(p), Some(c)) => Some(format!("{p}; {c}")),
147 (Some(w), None) | (None, Some(w)) => Some(w),
148 (None, None) => None,
149 };
150 (base, warn)
151 }
152}
153
154pub fn preset(name: &str) -> Option<Theme> {
156 Some(match name {
157 "default" => Theme::default(),
158 "tokyo-night" => Theme {
159 active: "#e0af68".into(),
160 blocked: "#f7768e".into(),
161 idle: "#9ece6a".into(),
162 ink: "#c0caf5".into(),
163 muted: "#787c99".into(),
164 faint: "#565f89".into(),
165 accent: "#7aa2f7".into(),
166 band: "#292e42".into(),
167 },
168 "catppuccin" => Theme {
169 active: "#f9e2af".into(),
170 blocked: "#f38ba8".into(),
171 idle: "#a6e3a1".into(),
172 ink: "#cdd6f4".into(),
173 muted: "#a6adc8".into(),
174 faint: "#6c7086".into(),
175 accent: "#89b4fa".into(),
176 band: "#313244".into(),
177 },
178 "gruvbox" => Theme {
179 active: "#d79921".into(),
180 blocked: "#cc241d".into(),
181 idle: "#98971a".into(),
182 ink: "#ebdbb2".into(),
183 muted: "#a89984".into(),
184 faint: "#665c54".into(),
185 accent: "#458588".into(),
186 band: "#3c3836".into(),
187 },
188 "nord" => Theme {
189 active: "#ebcb8b".into(),
190 blocked: "#bf616a".into(),
191 idle: "#a3be8c".into(),
192 ink: "#eceff4".into(),
193 muted: "#d8dee9".into(),
194 faint: "#4c566a".into(),
195 accent: "#88c0d0".into(),
196 band: "#3b4252".into(),
197 },
198 "dracula" => Theme {
199 active: "#f1fa8c".into(),
200 blocked: "#ff5555".into(),
201 idle: "#50fa7b".into(),
202 ink: "#f8f8f2".into(),
203 muted: "#b8b8c0".into(),
204 faint: "#6272a4".into(),
205 accent: "#bd93f9".into(),
206 band: "#44475a".into(),
207 },
208 "warm" => Theme {
212 active: "#c4643c".into(), blocked: "#c14a4a".into(), idle: "#8a9a5b".into(), ink: "#ece0cc".into(), muted: "#b39a7d".into(), faint: "#8a7355".into(), accent: "#e0a458".into(), band: "#3a2f28".into(), },
221 _ => return None,
222 })
223}
224
225#[cfg(feature = "ratatui")]
226mod palette {
227 use super::{parse_hex, Theme};
228 use ratatui::style::Color;
229
230 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
232 pub struct Palette {
233 pub active: Color,
234 pub blocked: Color,
235 pub idle: Color,
236 pub ink: Color,
237 pub muted: Color,
238 pub faint: Color,
239 pub accent: Color,
240 pub band: Color,
241 }
242
243 impl From<&Theme> for Palette {
244 fn from(t: &Theme) -> Self {
245 let d = Theme::default();
248 let slot = |value: &str, fallback: &str| {
249 let [r, g, b] = parse_hex(value)
250 .or_else(|| parse_hex(fallback))
251 .expect("default theme slots are valid hex");
252 Color::Rgb(r, g, b)
253 };
254 Palette {
255 active: slot(&t.active, &d.active),
256 blocked: slot(&t.blocked, &d.blocked),
257 idle: slot(&t.idle, &d.idle),
258 ink: slot(&t.ink, &d.ink),
259 muted: slot(&t.muted, &d.muted),
260 faint: slot(&t.faint, &d.faint),
261 accent: slot(&t.accent, &d.accent),
262 band: slot(&t.band, &d.band),
263 }
264 }
265 }
266
267 impl Default for Palette {
268 fn default() -> Self {
269 Palette::from(&Theme::default())
270 }
271 }
272
273 fn to_linear(c: u8) -> f64 {
277 let c = c as f64 / 255.0;
278 if c <= 0.040_45 {
279 c / 12.92
280 } else {
281 ((c + 0.055) / 1.055).powf(2.4)
282 }
283 }
284
285 fn to_srgb(c: f64) -> u8 {
286 let c = c.clamp(0.0, 1.0);
287 let v = if c <= 0.003_130_8 {
288 c * 12.92
289 } else {
290 1.055 * c.powf(1.0 / 2.4) - 0.055
291 };
292 (v * 255.0).round() as u8
293 }
294
295 pub fn blend(a: Color, b: Color, t: f64) -> Color {
302 let t = t.clamp(0.0, 1.0);
303 let (Color::Rgb(ar, ag, ab), Color::Rgb(br, bg, bb)) = (a, b) else {
304 return if t < 0.5 { a } else { b };
305 };
306 let mix = |x: u8, y: u8| to_srgb(to_linear(x) * (1.0 - t) + to_linear(y) * t);
307 Color::Rgb(mix(ar, br), mix(ag, bg), mix(ab, bb))
308 }
309
310 pub fn ramp(a: Color, b: Color, n: usize) -> Vec<Color> {
314 if n <= 1 {
315 return Vec::from_iter((n == 1).then_some(a));
316 }
317 (0..n)
318 .map(|i| blend(a, b, i as f64 / (n - 1) as f64))
319 .collect()
320 }
321}
322#[cfg(feature = "ratatui")]
323pub use palette::{blend, ramp, Palette};
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn preset_default_equals_theme_default() {
331 assert_eq!(preset("default"), Some(Theme::default()));
332 }
333 #[test]
334 fn default_is_tokyo_night() {
335 assert_eq!(Theme::default(), preset("tokyo-night").unwrap());
336 }
337 #[test]
338 fn preset_known_returns_palette() {
339 let t = preset("tokyo-night").expect("exists");
340 assert_eq!(t.blocked, "#f7768e");
341 assert_eq!(t.accent, "#7aa2f7");
342 }
343 #[test]
344 fn preset_unknown_is_none() {
345 assert_eq!(preset("nope"), None);
346 }
347 #[test]
348 fn density_parses_and_maps_to_spacing() {
349 assert_eq!(Density::parse("compact"), Some(Density::Compact));
350 assert_eq!(Density::parse(" Comfortable "), Some(Density::Comfortable));
351 assert_eq!(Density::parse("SPACIOUS"), Some(Density::Spacious));
352 assert_eq!(Density::parse("cozy"), None);
353 assert_eq!(Density::default(), Density::Comfortable);
354 assert_eq!(
356 (Density::Compact.blank_lines(), Density::Compact.gutter()),
357 (0, 0)
358 );
359 assert_eq!(Density::Comfortable.gutter(), 2);
360 assert_eq!(Density::Spacious.gutter(), 4);
361 }
362 #[test]
363 fn all_named_presets_exist() {
364 for name in [
365 "default",
366 "tokyo-night",
367 "catppuccin",
368 "gruvbox",
369 "nord",
370 "dracula",
371 "warm",
372 ] {
373 assert!(preset(name).is_some(), "missing {name}");
374 }
375 }
376 #[test]
377 fn warm_preset_is_low_blue() {
378 let w = preset("warm").expect("warm exists");
382 assert_ne!(w.ink, Theme::default().ink);
383 assert_eq!(w.accent, "#e0a458");
384 }
385 #[test]
386 fn resolve_no_preset_is_default() {
387 let (theme, warn) = ThemeConfig::default().resolve();
388 assert_eq!(theme, Theme::default());
389 assert!(warn.is_none());
390 }
391 #[test]
392 fn resolve_known_preset() {
393 let tc = ThemeConfig {
394 preset: Some("gruvbox".into()),
395 ..Default::default()
396 };
397 let (theme, warn) = tc.resolve();
398 assert_eq!(theme, preset("gruvbox").unwrap());
399 assert!(warn.is_none());
400 }
401 #[test]
402 fn resolve_unknown_preset_defaults_with_warning() {
403 let tc = ThemeConfig {
404 preset: Some("typo".into()),
405 ..Default::default()
406 };
407 let (theme, warn) = tc.resolve();
408 assert_eq!(theme, Theme::default());
409 assert!(warn.as_deref().unwrap().contains("typo"));
410 }
411 #[test]
412 fn resolve_preset_with_override() {
413 let tc = ThemeConfig {
414 preset: Some("gruvbox".into()),
415 blocked: Some("#ff0000".into()),
416 ..Default::default()
417 };
418 let (theme, _) = tc.resolve();
419 assert_eq!(theme.blocked, "#ff0000");
420 assert_eq!(theme.idle, preset("gruvbox").unwrap().idle);
421 }
422
423 #[test]
424 fn parse_hex_accepts_valid_with_and_without_hash() {
425 assert_eq!(parse_hex("#7ee07e"), Some([0x7e, 0xe0, 0x7e]));
426 assert_eq!(parse_hex("7ee07e"), Some([0x7e, 0xe0, 0x7e]));
427 }
428
429 #[test]
430 fn parse_hex_rejects_non_hex_and_wrong_length() {
431 assert_eq!(parse_hex("nonsense"), None);
432 assert_eq!(parse_hex("#fff"), None);
433 assert_eq!(parse_hex("#gggggg"), None);
434 }
435
436 #[test]
437 fn parse_hex_rejects_six_byte_multibyte_without_panicking() {
438 assert_eq!(parse_hex("aéaé"), None);
440 }
441
442 #[test]
443 fn resolve_ignores_invalid_color_and_warns() {
444 let tc = ThemeConfig {
445 active: Some("notacolor".into()),
446 ..Default::default()
447 };
448 let (theme, warn) = tc.resolve();
449 assert_eq!(
450 theme.active,
451 Theme::default().active,
452 "bad color keeps base"
453 );
454 let w = warn.expect("invalid color should warn");
455 assert!(
456 w.contains("active"),
457 "warning names the offending slot: {w}"
458 );
459 }
460
461 #[test]
462 fn resolve_reports_both_unknown_preset_and_bad_color() {
463 let tc = ThemeConfig {
464 preset: Some("typo".into()),
465 blocked: Some("xyz".into()),
466 ..Default::default()
467 };
468 let w = tc.resolve().1.expect("should warn");
469 assert!(w.contains("typo"), "mentions preset: {w}");
470 assert!(w.contains("blocked"), "mentions color: {w}");
471 }
472}
473
474#[cfg(all(test, feature = "ratatui"))]
475mod palette_tests {
476 use super::*;
477 use ratatui::style::Color;
478
479 #[test]
480 fn palette_from_theme_converts_hex_to_rgb() {
481 let p = Palette::from(&Theme::default());
482 assert_eq!(p.active, Color::Rgb(0xe0, 0xaf, 0x68));
484 assert_eq!(p.band, Color::Rgb(0x29, 0x2e, 0x42));
485 }
486
487 #[test]
488 fn invalid_slot_falls_back_to_default_theme_color() {
489 let t = Theme {
490 accent: "notacolor".into(),
491 ..Theme::default()
492 };
493 let p = Palette::from(&t);
494 assert_eq!(p.accent, Color::Rgb(0x7a, 0xa2, 0xf7));
495 }
496
497 #[test]
498 fn every_preset_resolves_to_a_palette() {
499 for name in [
500 "default",
501 "tokyo-night",
502 "catppuccin",
503 "gruvbox",
504 "nord",
505 "dracula",
506 ] {
507 let _ = Palette::from(&preset(name).expect("known preset"));
508 }
509 }
510}