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/// A lower-emphasis accent surface for pointer hover. On dark terminals it
63/// softens the selection wash toward a neutral dark surface; on light
64/// terminals it produces a pale accent tint.
65fn hover_tint(color: Color, light_background: bool) -> Color {
66    let (r, g, b) = rgb_of(color);
67    if light_background {
68        let pale = |channel: u8| ((u16::from(channel) + 5 * 255) / 6) as u8;
69        Color::Rgb(pale(r), pale(g), pale(b))
70    } else {
71        let (r, g, b) = rgb_of(tint(color));
72        let soften = |channel: u8| ((u16::from(channel) + 32) / 2) as u8;
73        Color::Rgb(soften(r), soften(g), soften(b))
74    }
75}
76
77fn contrasting_text(background: Color) -> Color {
78    let (r, g, b) = rgb_of(background);
79    let luminance = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1_000;
80    if luminance >= 128 {
81        Color::Indexed(16)
82    } else {
83        Color::Indexed(231)
84    }
85}
86
87/// The RGB behind a palette index.
88fn rgb_of(color: Color) -> (u8, u8, u8) {
89    let code = match color {
90        Color::Rgb(r, g, b) => return (r, g, b),
91        Color::Indexed(code) => code,
92        _ => return (0, 0, 0),
93    };
94    match code {
95        // The 6x6x6 cube, whose steps are 0 then 95 and up by 40.
96        16..232 => {
97            let level = |c: u8| if c == 0 { 0 } else { 55 + c * 40 };
98            let base = code - 16;
99            (level(base / 36), level((base % 36) / 6), level(base % 6))
100        }
101        // The grayscale ramp.
102        232.. => {
103            let v = 8 + (code - 232) * 10;
104            (v, v, v)
105        }
106        _ => (0, 0, 0),
107    }
108}
109
110pub struct Theme {
111    pub accent: Color,
112    colors_disabled: bool,
113    light_background: bool,
114}
115
116impl Theme {
117    pub fn new(name: &str) -> Self {
118        let colors_disabled = std::env::var_os("NO_COLOR").is_some()
119            || std::env::var("TERM").is_ok_and(|term| term == "dumb");
120        let light = terminal_background_is_light();
121        Self::with_environment(name, colors_disabled, light)
122    }
123
124    pub fn with_environment(name: &str, colors_disabled: bool, light_background: bool) -> Self {
125        let accent = if colors_disabled {
126            Color::Reset
127        } else if light_background {
128            Color::Indexed(match name {
129                "red" => 124,
130                "yellow" => 136,
131                "green" => 28,
132                "cyan" => 30,
133                "blue" => 25,
134                "purple" => 91,
135                "white" => 238,
136                "black" => 16,
137                _ => 25,
138            })
139        } else {
140            color(name)
141        };
142        Self {
143            accent,
144            colors_disabled,
145            light_background,
146        }
147    }
148
149    pub fn muted_color(&self) -> Color {
150        if self.colors_disabled {
151            Color::Reset
152        } else {
153            GREY
154        }
155    }
156
157    pub fn error_color(&self) -> Color {
158        if self.colors_disabled {
159            Color::Reset
160        } else {
161            RED
162        }
163    }
164
165    pub fn success_color(&self) -> Color {
166        if self.colors_disabled {
167            Color::Reset
168        } else {
169            GREEN
170        }
171    }
172
173    pub fn selection_wash(&self) -> Color {
174        if self.colors_disabled {
175            Color::Reset
176        } else {
177            tint(self.accent)
178        }
179    }
180
181    pub fn dimmed_accent(&self) -> Color {
182        if self.colors_disabled {
183            Color::Reset
184        } else {
185            dimmed(self.accent)
186        }
187    }
188
189    fn high_contrast_selection(&self) -> bool {
190        self.colors_disabled || self.light_background
191    }
192
193    /// Style of the selected row: a wash of the accent behind it, with
194    /// the text keeping whatever colour it already had.
195    pub fn selection(&self) -> Style {
196        if self.high_contrast_selection() {
197            Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
198        } else {
199            Style::new()
200                .bg(self.selection_wash())
201                .add_modifier(Modifier::BOLD)
202        }
203    }
204
205    /// Style of the selected row when its panel does not have focus.
206    /// Keeps the accent wash so the active category stays obvious while
207    /// the Tasks panel has keyboard focus; bold is reserved for focus.
208    pub fn selection_unfocused(&self) -> Style {
209        if self.high_contrast_selection() {
210            Style::new().add_modifier(Modifier::REVERSED)
211        } else {
212            Style::new().bg(self.selection_wash())
213        }
214    }
215
216    pub fn accent_text(&self) -> Style {
217        Style::new().fg(self.accent)
218    }
219
220    fn label_color(&self, color: LabelColor) -> Color {
221        Color::Indexed(if self.light_background {
222            match color {
223                LabelColor::Red => 124,
224                LabelColor::Orange => 166,
225                LabelColor::Yellow => 136,
226                LabelColor::Lime => 64,
227                LabelColor::Green => 28,
228                LabelColor::Teal => 29,
229                LabelColor::Cyan => 30,
230                LabelColor::Blue => 25,
231                LabelColor::Indigo => 55,
232                LabelColor::Purple => 91,
233                LabelColor::Pink => 125,
234                LabelColor::Brown => 94,
235            }
236        } else {
237            match color {
238                LabelColor::Red => 196,
239                LabelColor::Orange => 208,
240                LabelColor::Yellow => 226,
241                LabelColor::Lime => 118,
242                LabelColor::Green => 41,
243                LabelColor::Teal => 36,
244                LabelColor::Cyan => 45,
245                LabelColor::Blue => 39,
246                LabelColor::Indigo => 63,
247                LabelColor::Purple => 141,
248                LabelColor::Pink => 205,
249                LabelColor::Brown => 130,
250            }
251        })
252    }
253
254    fn completed_label_color(&self) -> Color {
255        Color::Indexed(if self.light_background { 238 } else { 244 })
256    }
257
258    /// Active labels keep their identity color under row focus; labels on a
259    /// completed task share a neutral gray completion style.
260    pub fn label_badge(&self, color: LabelColor, done: bool) -> Style {
261        if self.colors_disabled {
262            return Style::new().add_modifier(Modifier::REVERSED);
263        }
264        let background = if done {
265            self.completed_label_color()
266        } else {
267            self.label_color(color)
268        };
269        Style::new()
270            .fg(contrasting_text(background))
271            .bg(background)
272            .remove_modifier(Modifier::REVERSED)
273    }
274
275    /// A picker swatch is an inset glyph rather than a cell background, so
276    /// the surrounding selection brackets can visibly enclose it.
277    pub fn label_swatch(&self, color: LabelColor) -> Style {
278        if self.colors_disabled {
279            Style::new()
280        } else {
281            Style::new()
282                .fg(self.label_color(color))
283                .remove_modifier(Modifier::REVERSED)
284        }
285    }
286
287    /// Manager focus is neutral so it cannot be mistaken for label color.
288    pub fn label_focus(&self) -> Style {
289        if self.colors_disabled {
290            Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
291        } else {
292            Style::new()
293                .bg(Color::Indexed(if self.light_background {
294                    250
295                } else {
296                    240
297                }))
298                .add_modifier(Modifier::BOLD)
299        }
300    }
301
302    /// Lower-emphasis background for an unselected row. NO_COLOR cannot emit
303    /// RGB, so reverse video supplies the background and DIM keeps it below
304    /// the selected/focused state.
305    pub fn hover(&self) -> Style {
306        if self.colors_disabled {
307            Style::new().add_modifier(Modifier::DIM | Modifier::REVERSED)
308        } else {
309            Style::new().bg(hover_tint(self.accent, self.light_background))
310        }
311    }
312
313    /// Label-manager badges already own their background, so their hover
314    /// state also supplies a readable foreground.
315    pub fn label_hover(&self) -> Style {
316        if self.colors_disabled {
317            self.hover()
318        } else {
319            let background = hover_tint(self.accent, self.light_background);
320            Style::new()
321                .fg(contrasting_text(background))
322                .bg(background)
323                .remove_modifier(Modifier::REVERSED)
324        }
325    }
326
327    pub fn plain(&self) -> Style {
328        Style::new()
329    }
330}
331
332fn terminal_background_is_light() -> bool {
333    let Ok(value) = std::env::var("COLORFGBG") else {
334        return false;
335    };
336    value
337        .split([';', ':'])
338        .next_back()
339        .and_then(|value| value.parse::<u8>().ok())
340        .is_some_and(|background| matches!(background, 7 | 10..=15))
341}
342
343pub fn reduced_motion() -> bool {
344    std::env::var("MACH_REDUCED_MOTION").is_ok_and(|value| {
345        matches!(
346            value.to_ascii_lowercase().as_str(),
347            "1" | "true" | "yes" | "on"
348        )
349    })
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn no_color_and_light_terminals_use_contrast_instead_of_rgb_washes() {
358        let no_color = Theme::with_environment("cyan", true, false);
359        assert_eq!(no_color.accent, Color::Reset);
360        assert!(
361            no_color
362                .selection()
363                .add_modifier
364                .contains(Modifier::REVERSED)
365        );
366        assert_eq!(no_color.muted_color(), Color::Reset);
367        assert_eq!(no_color.error_color(), Color::Reset);
368        assert_eq!(no_color.success_color(), Color::Reset);
369        assert_eq!(no_color.selection_wash(), Color::Reset);
370        assert_eq!(no_color.dimmed_accent(), Color::Reset);
371        assert!(
372            no_color
373                .label_badge(LabelColor::Blue, false)
374                .add_modifier
375                .contains(Modifier::REVERSED)
376        );
377        assert!(
378            no_color
379                .hover()
380                .add_modifier
381                .contains(Modifier::DIM | Modifier::REVERSED),
382            "NO_COLOR still needs a background-like hover state"
383        );
384        assert!(!no_color.hover().add_modifier.contains(Modifier::BOLD));
385        assert!(!no_color.hover().add_modifier.contains(Modifier::UNDERLINED));
386        assert_eq!(no_color.hover().bg, None);
387
388        let light = Theme::with_environment("white", false, true);
389        assert_eq!(light.accent, Color::Indexed(238));
390        assert!(light.selection().add_modifier.contains(Modifier::REVERSED));
391        assert_eq!(
392            light.label_badge(LabelColor::Brown, false).bg,
393            Some(Color::Indexed(94))
394        );
395        assert_eq!(
396            light.label_badge(LabelColor::Brown, false).fg,
397            Some(Color::Indexed(231))
398        );
399        assert_eq!(
400            light.label_badge(LabelColor::Red, true).bg,
401            Some(Color::Indexed(238))
402        );
403        assert!(light.hover().bg.is_some());
404        assert!(!light.hover().add_modifier.contains(Modifier::UNDERLINED));
405        let dark_yellow = Theme::with_environment("yellow", false, false);
406        assert!(dark_yellow.hover().bg.is_some());
407        assert!(
408            !dark_yellow
409                .hover()
410                .add_modifier
411                .contains(Modifier::UNDERLINED)
412        );
413        assert_eq!(
414            dark_yellow.label_badge(LabelColor::Yellow, false).bg,
415            Some(Color::Indexed(226))
416        );
417        assert_eq!(
418            dark_yellow.label_badge(LabelColor::Yellow, false).fg,
419            Some(Color::Indexed(16))
420        );
421        assert_eq!(
422            dark_yellow.label_badge(LabelColor::Red, true).bg,
423            Some(Color::Indexed(244))
424        );
425        assert_eq!(
426            dark_yellow.label_badge(LabelColor::Blue, true).bg,
427            Some(Color::Indexed(244)),
428            "completed task labels share one neutral background"
429        );
430        assert_eq!(dark_yellow.label_focus().bg, Some(Color::Indexed(240)));
431    }
432}