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/// Transcript spacing. A terminal can't change point size, so "roomier" is
45/// expressed as vertical space between turns plus a left gutter the role
46/// spine lives in. `Comfortable` is the default; `Compact` is the pre-0.5
47/// edge-to-edge look.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum Density {
50    Compact,
51    #[default]
52    Comfortable,
53    Spacious,
54}
55
56impl Density {
57    /// Parse a config value. `None` for anything unrecognized, so the caller
58    /// can warn and fall back — the same contract as an unknown theme preset.
59    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    /// Blank lines inserted between one turn and the next.
69    pub fn blank_lines(self) -> usize {
70        match self {
71            Density::Compact => 0,
72            Density::Comfortable | Density::Spacious => 1,
73        }
74    }
75
76    /// Left-gutter width in columns — where the role spine is drawn.
77    pub fn gutter(self) -> usize {
78        match self {
79            Density::Compact => 0,
80            Density::Comfortable => 2,
81            Density::Spacious => 4,
82        }
83    }
84}
85
86/// Parse a `#rrggbb` (or `rrggbb`) hex color into its RGB bytes. Returns `None`
87/// for any string that isn't exactly six hex digits — the single source of
88/// truth for what counts as a valid color, shared by config validation and the
89/// TUI renderer.
90pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
91    let s = s.strip_prefix('#').unwrap_or(s);
92    // Guard on the hex-digit charset first: this also guarantees ASCII, so the
93    // byte slices below always land on char boundaries (no panic on multi-byte
94    // input like "aéaé", which is 6 bytes but not 6 hex digits).
95    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    /// Resolve to a concrete palette. Unknown preset name -> default base + a
107    /// warning; set color fields override the base slot-by-slot. Any override
108    /// that isn't a valid `#rrggbb` color is ignored (base kept) and reported
109    /// in the warning rather than silently swallowed downstream.
110    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        // Surface both problems if present; neither is silently dropped.
145        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
154/// Look up a built-in palette by name. `None` if the name is unknown.
155pub 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, low-blue palette — paper-white ink on a soft brown band,
209        // amber accent, terracotta "active". Deliberately the antidote to
210        // the cool blue-grey default; opt in with `preset = "warm"`.
211        "warm" => Theme {
212            active: "#c4643c".into(),  // terracotta — a tool working
213            blocked: "#c14a4a".into(), // brick red — blocked/failed
214            idle: "#8a9a5b".into(),    // olive — done/idle
215            ink: "#ece0cc".into(),     // warm paper white — body text
216            muted: "#b39a7d".into(),   // tan — details, notices
217            faint: "#8a7355".into(),   // soft brown — continuation bar
218            accent: "#e0a458".into(),  // amber — the assistant marker, bullets
219            band: "#3a2f28".into(),    // warm dark — strip/code background
220        },
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    /// The resolved theme as terminal colors — what views consume.
231    #[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            // Fallback per slot is the default theme's color, so there is no
246            // second hand-written RGB table to drift out of sync.
247            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    /// sRGB → linear light. Blending in gamma-encoded bytes darkens the middle
274    /// of a ramp (the classic muddy midpoint); decoding first is what keeps a
275    /// faint→accent sweep reading as one hue moving, not as a dip.
276    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    /// Blend two colors in linear light, `t` clamped to `0.0..=1.0`.
296    ///
297    /// Only `Color::Rgb` can be interpolated. Every `Palette` slot is `Rgb` by
298    /// construction, but `Color` is a wider enum than the palette can produce,
299    /// so a non-`Rgb` input degrades to a hard switch at the midpoint rather
300    /// than to a panic or a wrong color.
301    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    /// `n` colors stepping evenly from `a` to `b`, both endpoints included.
311    /// `n == 1` yields `a` alone (no midpoint to speak of); `n == 0` yields
312    /// nothing.
313    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        // Compact reproduces today's edge-to-edge, no-blank look.
355        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        // The whole point of "warm" is that its ink and accent are not the
379        // cool blue-grey of the default — guard against a copy-paste that
380        // reintroduces a blue.
381        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        // "aéaé" is 6 bytes but not char-aligned at 2/4; must reject, not panic.
439        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        // tokyo-night, the default palette
483        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}