Skip to main content

putzen_cli/caches/tui/
widgets.rs

1//! Theme + small reusable rendering helpers.
2
3use ratatui::style::{Color, Modifier, Style};
4
5pub struct Theme {
6    pub bg: Color,
7    pub bg_sel: Color,
8    pub bg_modal: Color,
9    pub fg: Color,
10    pub fg_bright: Color,
11    pub border: Color,
12    pub title: Color,
13    pub header: Color,
14    pub gutter_active: Color,
15    pub gutter_marked: Color,
16    pub hot: Color,
17    pub warm: Color,
18    pub ok: Color,
19    pub dim: Color,
20}
21
22impl Theme {
23    pub const GRUVBOX: Theme = Theme {
24        bg: Color::Rgb(0x28, 0x28, 0x28),
25        bg_sel: Color::Rgb(0x3c, 0x38, 0x36), // gruvbox bg1
26        bg_modal: Color::Rgb(0x1d, 0x20, 0x21),
27        fg: Color::Rgb(0xeb, 0xdb, 0xb2),
28        fg_bright: Color::Rgb(0xfb, 0xf1, 0xc7),
29        border: Color::Rgb(0x92, 0x83, 0x74),
30        title: Color::Rgb(0x8e, 0xc0, 0x7c),
31        header: Color::Rgb(0x83, 0xa5, 0x98),
32        gutter_active: Color::Rgb(0xfa, 0xbd, 0x2f),
33        gutter_marked: Color::Rgb(0xfe, 0x80, 0x19),
34        hot: Color::Rgb(0xfb, 0x49, 0x34),
35        warm: Color::Rgb(0xfe, 0x80, 0x19),
36        ok: Color::Rgb(0xb8, 0xbb, 0x26),
37        dim: Color::Rgb(0x92, 0x83, 0x74),
38    };
39
40    pub fn block_style(&self) -> Style {
41        Style::default().fg(self.border).bg(self.bg)
42    }
43    pub fn title_style(&self) -> Style {
44        Style::default().fg(self.title).add_modifier(Modifier::BOLD)
45    }
46    pub fn header_style(&self) -> Style {
47        Style::default()
48            .fg(self.header)
49            .add_modifier(Modifier::BOLD)
50    }
51    pub fn gutter_active_style(&self) -> Style {
52        Style::default().fg(self.gutter_active)
53    }
54    pub fn gutter_marked_style(&self) -> Style {
55        Style::default().fg(self.gutter_marked)
56    }
57    pub fn body_style(&self) -> Style {
58        Style::default().fg(self.fg).bg(self.bg)
59    }
60    pub fn modal_block_style(&self) -> Style {
61        Style::default().fg(self.gutter_active).bg(self.bg_modal)
62    }
63    pub fn modal_body_style(&self) -> Style {
64        Style::default().fg(self.fg_bright).bg(self.bg_modal)
65    }
66    pub fn dim_style(&self) -> Style {
67        Style::default().fg(self.dim)
68    }
69
70    /// Smooth heat-map between `ok`, `warm`, and `hot` keyed by `t ∈ [0, 1]`:
71    /// `0.0` is pure `ok` (low score), `0.5` is pure `warm`, `1.0` is pure
72    /// `hot` (highest score in the visible set).  Values outside the range
73    /// are clamped.  Used for the score-bar colour.
74    pub fn score_color(&self, t: f64) -> Color {
75        let t = t.clamp(0.0, 1.0);
76        if t <= 0.5 {
77            lerp_rgb(self.ok, self.warm, t * 2.0)
78        } else {
79            lerp_rgb(self.warm, self.hot, (t - 0.5) * 2.0)
80        }
81    }
82}
83
84fn lerp_rgb(a: Color, b: Color, t: f64) -> Color {
85    let (ar, ag, ab) = rgb(a);
86    let (br, bg, bb) = rgb(b);
87    let mix = |x: u8, y: u8| ((x as f64) + ((y as f64) - (x as f64)) * t).round() as u8;
88    Color::Rgb(mix(ar, br), mix(ag, bg), mix(ab, bb))
89}
90
91fn rgb(c: Color) -> (u8, u8, u8) {
92    if let Color::Rgb(r, g, b) = c {
93        (r, g, b)
94    } else {
95        // Theme palette is RGB by construction; non-RGB only reachable if
96        // someone passes a named colour, in which case we render a flat grey.
97        (0x80, 0x80, 0x80)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn score_color_endpoints_match_palette() {
107        let t = Theme::GRUVBOX;
108        assert_eq!(t.score_color(0.0), t.ok);
109        assert_eq!(t.score_color(0.5), t.warm);
110        assert_eq!(t.score_color(1.0), t.hot);
111    }
112
113    #[test]
114    fn score_color_clamps_out_of_range() {
115        let t = Theme::GRUVBOX;
116        assert_eq!(t.score_color(-1.0), t.ok);
117        assert_eq!(t.score_color(2.0), t.hot);
118    }
119
120    #[test]
121    fn score_color_blends_between_anchors() {
122        let t = Theme::GRUVBOX;
123        // Quarter-way: half-way between ok and warm.
124        let q = t.score_color(0.25);
125        let (qr, qg, qb) = rgb(q);
126        let (ok_r, ok_g, ok_b) = rgb(t.ok);
127        let (wa_r, wa_g, wa_b) = rgb(t.warm);
128        let mid = |a: u8, b: u8| ((a as u16 + b as u16) / 2) as u8;
129        // Allow ±1 for round-off.
130        assert!((qr as i16 - mid(ok_r, wa_r) as i16).abs() <= 1);
131        assert!((qg as i16 - mid(ok_g, wa_g) as i16).abs() <= 1);
132        assert!((qb as i16 - mid(ok_b, wa_b) as i16).abs() <= 1);
133    }
134}