Skip to main content

hotl_theme/
lib.rs

1//! The hotl theme: config model, named presets, and (feature "ratatui") the
2//! resolved terminal palette. One crate owns the pipeline from config string
3//! to Color; both the watch dashboard and the execute TUI consume it.
4
5use 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        // The out-of-the-box palette is tokyo-night; `preset("default")` is
23        // its alias. One source of values — the preset table below.
24        preset("tokyo-night").expect("built-in preset")
25    }
26}
27
28/// Theme selection: an optional `preset` base palette plus optional per-slot
29/// color overrides. Absent fields fall back to the preset (or default).
30#[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/// Parse a `#rrggbb` (or `rrggbb`) hex color into its RGB bytes. Returns `None`
45/// for any string that isn't exactly six hex digits — the single source of
46/// truth for what counts as a valid color, shared by config validation and the
47/// TUI renderer.
48pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
49    let s = s.strip_prefix('#').unwrap_or(s);
50    // Guard on the hex-digit charset first: this also guarantees ASCII, so the
51    // byte slices below always land on char boundaries (no panic on multi-byte
52    // input like "aéaé", which is 6 bytes but not 6 hex digits).
53    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    /// Resolve to a concrete palette. Unknown preset name -> default base + a
65    /// warning; set color fields override the base slot-by-slot. Any override
66    /// that isn't a valid `#rrggbb` color is ignored (base kept) and reported
67    /// in the warning rather than silently swallowed downstream.
68    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        // Surface both problems if present; neither is silently dropped.
103        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
112/// Look up a built-in palette by name. `None` if the name is unknown.
113pub 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    /// The resolved theme as terminal colors — what views consume.
176    #[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            // Fallback per slot is the default theme's color, so there is no
191            // second hand-written RGB table to drift out of sync.
192            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        // "aéaé" is 6 bytes but not char-aligned at 2/4; must reject, not panic.
310        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        // tokyo-night, the default palette
354        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}