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
5use crate::model::LabelColor;
6
7pub fn color(name: &str) -> Color {
8    Color::Indexed(match name {
9        "red" => 160,
10        "yellow" => 226,
11        "green" => 41,
12        "cyan" => 37,
13        "blue" => 39,
14        "purple" => 141,
15        "white" => 231,
16        "black" => 234,
17        "grey" => 244,
18        _ => 39,
19    })
20}
21
22pub const RED: Color = Color::Indexed(196);
23pub const GREEN: Color = Color::Indexed(41);
24pub const GREY: Color = Color::Indexed(244);
25
26/// Dim a 256-colour code by one step in each RGB component, mirroring the
27/// dimming used for completed tasks that still carry a due date.
28pub fn dimmed(c: Color) -> Color {
29    let Color::Indexed(code) = c else { return c };
30    let dim = match code {
31        0..=15 => {
32            if code >= 8 {
33                code - 8
34            } else {
35                code
36            }
37        }
38        232.. => code.saturating_sub(4).max(232),
39        _ => {
40            let base = code - 16;
41            let (r, g, b) = (base / 36, (base % 36) / 6, base % 6);
42            16 + 36 * r.saturating_sub(1) + 6 * g.saturating_sub(1) + b.saturating_sub(1)
43        }
44    };
45    Color::Indexed(dim)
46}
47
48/// A wash of the accent: the same hue at about a quarter intensity,
49/// lifted off black so text — including the muted grey of a finished
50/// task — stays legible on top of it.
51///
52/// The 256-colour cube cannot express this: its darkest non-zero step is
53/// already 95, which is bright enough to fight mid-grey text. So the
54/// wash is given in RGB, which every terminal mach draws pictures in
55/// supports anyway.
56pub fn tint(color: Color) -> Color {
57    let (r, g, b) = rgb_of(color);
58    let wash = |c: u8| (c / 4).saturating_add(8);
59    Color::Rgb(wash(r), wash(g), wash(b))
60}
61
62/// The RGB behind a palette index.
63fn rgb_of(color: Color) -> (u8, u8, u8) {
64    let code = match color {
65        Color::Rgb(r, g, b) => return (r, g, b),
66        Color::Indexed(code) => code,
67        _ => return (0, 0, 0),
68    };
69    match code {
70        // The 6x6x6 cube, whose steps are 0 then 95 and up by 40.
71        16..232 => {
72            let level = |c: u8| if c == 0 { 0 } else { 55 + c * 40 };
73            let base = code - 16;
74            (level(base / 36), level((base % 36) / 6), level(base % 6))
75        }
76        // The grayscale ramp.
77        232.. => {
78            let v = 8 + (code - 232) * 10;
79            (v, v, v)
80        }
81        _ => (0, 0, 0),
82    }
83}
84
85pub struct Theme {
86    pub accent: Color,
87    high_contrast_selection: bool,
88    colors_disabled: bool,
89    light_background: bool,
90}
91
92impl Theme {
93    pub fn new(name: &str) -> Self {
94        let colors_disabled = std::env::var_os("NO_COLOR").is_some()
95            || std::env::var("TERM").is_ok_and(|term| term == "dumb");
96        let light = terminal_background_is_light();
97        Self::with_environment(name, colors_disabled, light)
98    }
99
100    pub fn with_environment(name: &str, colors_disabled: bool, light_background: bool) -> Self {
101        let accent = if colors_disabled {
102            Color::Reset
103        } else if light_background {
104            Color::Indexed(match name {
105                "red" => 124,
106                "yellow" => 136,
107                "green" => 28,
108                "cyan" => 30,
109                "blue" => 25,
110                "purple" => 91,
111                "white" => 238,
112                "black" => 16,
113                _ => 25,
114            })
115        } else {
116            color(name)
117        };
118        Self {
119            accent,
120            high_contrast_selection: colors_disabled || light_background,
121            colors_disabled,
122            light_background,
123        }
124    }
125
126    pub fn muted_color(&self) -> Color {
127        if self.colors_disabled {
128            Color::Reset
129        } else {
130            GREY
131        }
132    }
133
134    pub fn error_color(&self) -> Color {
135        if self.colors_disabled {
136            Color::Reset
137        } else {
138            RED
139        }
140    }
141
142    pub fn success_color(&self) -> Color {
143        if self.colors_disabled {
144            Color::Reset
145        } else {
146            GREEN
147        }
148    }
149
150    pub fn selection_wash(&self) -> Color {
151        if self.colors_disabled {
152            Color::Reset
153        } else {
154            tint(self.accent)
155        }
156    }
157
158    pub fn dimmed_accent(&self) -> Color {
159        if self.colors_disabled {
160            Color::Reset
161        } else {
162            dimmed(self.accent)
163        }
164    }
165
166    /// Style of the selected row: a wash of the accent behind it, with
167    /// the text keeping whatever colour it already had.
168    pub fn selection(&self) -> Style {
169        if self.high_contrast_selection {
170            Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
171        } else {
172            Style::new()
173                .bg(self.selection_wash())
174                .add_modifier(Modifier::BOLD)
175        }
176    }
177
178    /// Style of the selected row when its panel does not have focus.
179    /// Keeps the accent wash so the active category stays obvious while
180    /// the Tasks panel has keyboard focus; bold is reserved for focus.
181    pub fn selection_unfocused(&self) -> Style {
182        if self.high_contrast_selection {
183            Style::new().add_modifier(Modifier::REVERSED)
184        } else {
185            Style::new().bg(self.selection_wash())
186        }
187    }
188
189    pub fn accent_text(&self) -> Style {
190        Style::new().fg(self.accent)
191    }
192
193    fn label_color(&self, color: LabelColor) -> Color {
194        Color::Indexed(if self.light_background {
195            match color {
196                LabelColor::Red => 124,
197                LabelColor::Orange => 166,
198                LabelColor::Yellow => 136,
199                LabelColor::Lime => 64,
200                LabelColor::Green => 28,
201                LabelColor::Teal => 29,
202                LabelColor::Cyan => 30,
203                LabelColor::Blue => 25,
204                LabelColor::Indigo => 55,
205                LabelColor::Purple => 91,
206                LabelColor::Pink => 125,
207                LabelColor::Brown => 94,
208            }
209        } else {
210            match color {
211                LabelColor::Red => 196,
212                LabelColor::Orange => 208,
213                LabelColor::Yellow => 226,
214                LabelColor::Lime => 118,
215                LabelColor::Green => 41,
216                LabelColor::Teal => 36,
217                LabelColor::Cyan => 45,
218                LabelColor::Blue => 39,
219                LabelColor::Indigo => 63,
220                LabelColor::Purple => 141,
221                LabelColor::Pink => 205,
222                LabelColor::Brown => 130,
223            }
224        })
225    }
226
227    fn completed_label_color(&self) -> Color {
228        Color::Indexed(if self.light_background { 238 } else { 244 })
229    }
230
231    /// Active labels keep their identity color under row focus; labels on a
232    /// completed task share a neutral gray completion style.
233    pub fn label_badge(&self, color: LabelColor, done: bool) -> Style {
234        if self.colors_disabled {
235            return Style::new().add_modifier(Modifier::REVERSED);
236        }
237        let background = if done {
238            self.completed_label_color()
239        } else {
240            self.label_color(color)
241        };
242        let (r, g, b) = rgb_of(background);
243        let luminance = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1_000;
244        let foreground = if luminance >= 128 {
245            Color::Indexed(16)
246        } else {
247            Color::Indexed(231)
248        };
249        Style::new()
250            .fg(foreground)
251            .bg(background)
252            .remove_modifier(Modifier::REVERSED)
253    }
254
255    /// A picker swatch is an inset glyph rather than a cell background, so
256    /// the surrounding selection brackets can visibly enclose it.
257    pub fn label_swatch(&self, color: LabelColor) -> Style {
258        if self.colors_disabled {
259            Style::new()
260        } else {
261            Style::new()
262                .fg(self.label_color(color))
263                .remove_modifier(Modifier::REVERSED)
264        }
265    }
266
267    /// Manager focus is neutral so it cannot be mistaken for label color.
268    pub fn label_focus(&self) -> Style {
269        if self.colors_disabled {
270            Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
271        } else {
272            Style::new()
273                .bg(Color::Indexed(if self.light_background {
274                    250
275                } else {
276                    240
277                }))
278                .add_modifier(Modifier::BOLD)
279        }
280    }
281
282    pub fn plain(&self) -> Style {
283        Style::new()
284    }
285}
286
287fn terminal_background_is_light() -> bool {
288    let Ok(value) = std::env::var("COLORFGBG") else {
289        return false;
290    };
291    value
292        .split([';', ':'])
293        .next_back()
294        .and_then(|value| value.parse::<u8>().ok())
295        .is_some_and(|background| matches!(background, 7 | 10..=15))
296}
297
298pub fn reduced_motion() -> bool {
299    std::env::var("MACH_REDUCED_MOTION").is_ok_and(|value| {
300        matches!(
301            value.to_ascii_lowercase().as_str(),
302            "1" | "true" | "yes" | "on"
303        )
304    })
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn no_color_and_light_terminals_use_contrast_instead_of_rgb_washes() {
313        let no_color = Theme::with_environment("cyan", true, false);
314        assert_eq!(no_color.accent, Color::Reset);
315        assert!(
316            no_color
317                .selection()
318                .add_modifier
319                .contains(Modifier::REVERSED)
320        );
321        assert_eq!(no_color.muted_color(), Color::Reset);
322        assert_eq!(no_color.error_color(), Color::Reset);
323        assert_eq!(no_color.success_color(), Color::Reset);
324        assert_eq!(no_color.selection_wash(), Color::Reset);
325        assert_eq!(no_color.dimmed_accent(), Color::Reset);
326        assert!(
327            no_color
328                .label_badge(LabelColor::Blue, false)
329                .add_modifier
330                .contains(Modifier::REVERSED)
331        );
332
333        let light = Theme::with_environment("white", false, true);
334        assert_eq!(light.accent, Color::Indexed(238));
335        assert!(light.selection().add_modifier.contains(Modifier::REVERSED));
336        assert_eq!(
337            light.label_badge(LabelColor::Brown, false).bg,
338            Some(Color::Indexed(94))
339        );
340        assert_eq!(
341            light.label_badge(LabelColor::Brown, false).fg,
342            Some(Color::Indexed(231))
343        );
344        assert_eq!(
345            light.label_badge(LabelColor::Red, true).bg,
346            Some(Color::Indexed(238))
347        );
348        let dark_yellow = Theme::with_environment("yellow", false, false);
349        assert_eq!(
350            dark_yellow.label_badge(LabelColor::Yellow, false).bg,
351            Some(Color::Indexed(226))
352        );
353        assert_eq!(
354            dark_yellow.label_badge(LabelColor::Yellow, false).fg,
355            Some(Color::Indexed(16))
356        );
357        assert_eq!(
358            dark_yellow.label_badge(LabelColor::Red, true).bg,
359            Some(Color::Indexed(244))
360        );
361        assert_eq!(
362            dark_yellow.label_badge(LabelColor::Blue, true).bg,
363            Some(Color::Indexed(244)),
364            "completed task labels share one neutral background"
365        );
366        assert_eq!(dark_yellow.label_focus().bg, Some(Color::Indexed(240)));
367    }
368}