Skip to main content

mach/
theme.rs

1//! 256-colour palette and the styles the UI is built from.
2
3use ratatui::style::{Color, Modifier, Style};
4
5pub fn color(name: &str) -> Color {
6    Color::Indexed(match name {
7        "red" => 160,
8        "yellow" => 226,
9        "green" => 41,
10        "cyan" => 37,
11        "blue" => 39,
12        "purple" => 141,
13        "white" => 231,
14        "black" => 234,
15        "grey" => 244,
16        _ => 39,
17    })
18}
19
20pub const RED: Color = Color::Indexed(196);
21pub const GREEN: Color = Color::Indexed(41);
22pub const GREY: Color = Color::Indexed(244);
23
24/// Dim a 256-colour code by one step in each RGB component, mirroring the
25/// dimming used for completed tasks that still carry a due date.
26pub fn dimmed(c: Color) -> Color {
27    let Color::Indexed(code) = c else { return c };
28    let dim = match code {
29        0..=15 => {
30            if code >= 8 {
31                code - 8
32            } else {
33                code
34            }
35        }
36        232.. => code.saturating_sub(4).max(232),
37        _ => {
38            let base = code - 16;
39            let (r, g, b) = (base / 36, (base % 36) / 6, base % 6);
40            16 + 36 * r.saturating_sub(1) + 6 * g.saturating_sub(1) + b.saturating_sub(1)
41        }
42    };
43    Color::Indexed(dim)
44}
45
46/// A wash of the accent: the same hue at about a quarter intensity,
47/// lifted off black so text — including the muted grey of a finished
48/// task — stays legible on top of it.
49///
50/// The 256-colour cube cannot express this: its darkest non-zero step is
51/// already 95, which is bright enough to fight mid-grey text. So the
52/// wash is given in RGB, which every terminal mach draws pictures in
53/// supports anyway.
54pub fn tint(color: Color) -> Color {
55    let (r, g, b) = rgb_of(color);
56    let wash = |c: u8| (c / 4).saturating_add(8);
57    Color::Rgb(wash(r), wash(g), wash(b))
58}
59
60/// The RGB behind a palette index.
61fn rgb_of(color: Color) -> (u8, u8, u8) {
62    let code = match color {
63        Color::Rgb(r, g, b) => return (r, g, b),
64        Color::Indexed(code) => code,
65        _ => return (0, 0, 0),
66    };
67    match code {
68        // The 6x6x6 cube, whose steps are 0 then 95 and up by 40.
69        16..232 => {
70            let level = |c: u8| if c == 0 { 0 } else { 55 + c * 40 };
71            let base = code - 16;
72            (level(base / 36), level((base % 36) / 6), level(base % 6))
73        }
74        // The grayscale ramp.
75        232.. => {
76            let v = 8 + (code - 232) * 10;
77            (v, v, v)
78        }
79        _ => (0, 0, 0),
80    }
81}
82
83pub struct Theme {
84    pub accent: Color,
85    high_contrast_selection: bool,
86    colors_disabled: bool,
87}
88
89impl Theme {
90    pub fn new(name: &str) -> Self {
91        let colors_disabled = std::env::var_os("NO_COLOR").is_some()
92            || std::env::var("TERM").is_ok_and(|term| term == "dumb");
93        let light = terminal_background_is_light();
94        Self::with_environment(name, colors_disabled, light)
95    }
96
97    pub fn with_environment(name: &str, colors_disabled: bool, light_background: bool) -> Self {
98        let accent = if colors_disabled {
99            Color::Reset
100        } else if light_background {
101            Color::Indexed(match name {
102                "red" => 124,
103                "yellow" => 136,
104                "green" => 28,
105                "cyan" => 30,
106                "blue" => 25,
107                "purple" => 91,
108                "white" => 238,
109                "black" => 16,
110                _ => 25,
111            })
112        } else {
113            color(name)
114        };
115        Self {
116            accent,
117            high_contrast_selection: colors_disabled || light_background,
118            colors_disabled,
119        }
120    }
121
122    pub fn muted_color(&self) -> Color {
123        if self.colors_disabled {
124            Color::Reset
125        } else {
126            GREY
127        }
128    }
129
130    pub fn error_color(&self) -> Color {
131        if self.colors_disabled {
132            Color::Reset
133        } else {
134            RED
135        }
136    }
137
138    pub fn success_color(&self) -> Color {
139        if self.colors_disabled {
140            Color::Reset
141        } else {
142            GREEN
143        }
144    }
145
146    pub fn selection_wash(&self) -> Color {
147        if self.colors_disabled {
148            Color::Reset
149        } else {
150            tint(self.accent)
151        }
152    }
153
154    pub fn dimmed_accent(&self) -> Color {
155        if self.colors_disabled {
156            Color::Reset
157        } else {
158            dimmed(self.accent)
159        }
160    }
161
162    /// Style of the selected row: a wash of the accent behind it, with
163    /// the text keeping whatever colour it already had.
164    pub fn selection(&self) -> Style {
165        if self.high_contrast_selection {
166            Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
167        } else {
168            Style::new()
169                .bg(self.selection_wash())
170                .add_modifier(Modifier::BOLD)
171        }
172    }
173
174    /// Style of the selected row when its panel does not have focus.
175    /// Keeps the accent wash so the active category stays obvious while
176    /// the Tasks panel has keyboard focus; bold is reserved for focus.
177    pub fn selection_unfocused(&self) -> Style {
178        if self.high_contrast_selection {
179            Style::new().add_modifier(Modifier::REVERSED)
180        } else {
181            Style::new().bg(self.selection_wash())
182        }
183    }
184
185    pub fn accent_text(&self) -> Style {
186        Style::new().fg(self.accent)
187    }
188
189    pub fn plain(&self) -> Style {
190        Style::new()
191    }
192}
193
194fn terminal_background_is_light() -> bool {
195    let Ok(value) = std::env::var("COLORFGBG") else {
196        return false;
197    };
198    value
199        .split([';', ':'])
200        .next_back()
201        .and_then(|value| value.parse::<u8>().ok())
202        .is_some_and(|background| matches!(background, 7 | 10..=15))
203}
204
205pub fn reduced_motion() -> bool {
206    std::env::var("MACH_REDUCED_MOTION").is_ok_and(|value| {
207        matches!(
208            value.to_ascii_lowercase().as_str(),
209            "1" | "true" | "yes" | "on"
210        )
211    })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn no_color_and_light_terminals_use_contrast_instead_of_rgb_washes() {
220        let no_color = Theme::with_environment("cyan", true, false);
221        assert_eq!(no_color.accent, Color::Reset);
222        assert!(
223            no_color
224                .selection()
225                .add_modifier
226                .contains(Modifier::REVERSED)
227        );
228        assert_eq!(no_color.muted_color(), Color::Reset);
229        assert_eq!(no_color.error_color(), Color::Reset);
230        assert_eq!(no_color.success_color(), Color::Reset);
231        assert_eq!(no_color.selection_wash(), Color::Reset);
232        assert_eq!(no_color.dimmed_accent(), Color::Reset);
233
234        let light = Theme::with_environment("white", false, true);
235        assert_eq!(light.accent, Color::Indexed(238));
236        assert!(light.selection().add_modifier.contains(Modifier::REVERSED));
237    }
238}