Skip to main content

teamctl_ui/
theme.rs

1//! Terminal capability detection — picks the colour fidelity teamctl-ui
2//! renders at. Read once at startup; the rest of the UI passes the
3//! `Capabilities` value down to widgets so colour choices stay
4//! consistent across a single session even if the env mutates.
5
6use ratatui::style::Color;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ColorMode {
10    /// `COLORTERM=truecolor|24bit` → 24-bit RGB available.
11    TrueColor,
12    /// `TERM=*-256color` → 256-colour palette.
13    Palette256,
14    /// Generic ANSI `TERM` (e.g. `xterm`, `screen`) → 16-colour palette.
15    Ansi16,
16    /// `TERM=dumb` or `NO_COLOR` set → render in plain glyphs.
17    Monochrome,
18}
19
20#[derive(Debug, Clone, Copy)]
21pub struct Capabilities {
22    pub color: ColorMode,
23}
24
25impl Capabilities {
26    /// Foreground accent for focus rings, statusline keybindings, and
27    /// the splash logo. Falls back to plain `Reset` when colour is
28    /// disabled so widgets don't have to branch on `ColorMode`.
29    pub fn accent(self) -> Color {
30        match self.color {
31            ColorMode::TrueColor => Color::Rgb(0xfb, 0x73, 0x85),
32            ColorMode::Palette256 => Color::Indexed(204),
33            ColorMode::Ansi16 => Color::LightMagenta,
34            ColorMode::Monochrome => Color::Reset,
35        }
36    }
37
38    /// Dim text colour for empty-state placeholders and inactive
39    /// statusline hints.
40    pub fn muted(self) -> Color {
41        match self.color {
42            ColorMode::TrueColor => Color::Rgb(0x88, 0x88, 0x88),
43            ColorMode::Palette256 => Color::Indexed(244),
44            ColorMode::Ansi16 => Color::DarkGray,
45            ColorMode::Monochrome => Color::Reset,
46        }
47    }
48}
49
50/// Detect terminal colour fidelity from environment variables. Honors
51/// `NO_COLOR` (unconditional monochrome — see https://no-color.org)
52/// and `TERM=dumb` first, then `COLORTERM`, then `TERM` substring.
53pub fn detect_capabilities() -> Capabilities {
54    Capabilities {
55        color: detect_color_from_env(|k| std::env::var(k).ok()),
56    }
57}
58
59fn detect_color_from_env<F: Fn(&str) -> Option<String>>(get: F) -> ColorMode {
60    if get("NO_COLOR").is_some() {
61        return ColorMode::Monochrome;
62    }
63    let term = get("TERM").unwrap_or_default();
64    if term == "dumb" || term.is_empty() {
65        return ColorMode::Monochrome;
66    }
67    if let Some(ct) = get("COLORTERM") {
68        let lower = ct.to_ascii_lowercase();
69        if lower == "truecolor" || lower == "24bit" {
70            return ColorMode::TrueColor;
71        }
72    }
73    if term.contains("256color") {
74        return ColorMode::Palette256;
75    }
76    ColorMode::Ansi16
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
84        move |k| {
85            pairs
86                .iter()
87                .find(|(name, _)| *name == k)
88                .map(|(_, v)| (*v).to_string())
89        }
90    }
91
92    #[test]
93    fn no_color_wins_over_everything() {
94        let mode = detect_color_from_env(env(&[
95            ("NO_COLOR", "1"),
96            ("COLORTERM", "truecolor"),
97            ("TERM", "xterm-256color"),
98        ]));
99        assert_eq!(mode, ColorMode::Monochrome);
100    }
101
102    #[test]
103    fn dumb_term_is_monochrome() {
104        assert_eq!(
105            detect_color_from_env(env(&[("TERM", "dumb")])),
106            ColorMode::Monochrome
107        );
108    }
109
110    #[test]
111    fn truecolor_when_colorterm_set() {
112        assert_eq!(
113            detect_color_from_env(env(&[
114                ("COLORTERM", "truecolor"),
115                ("TERM", "xterm-256color"),
116            ])),
117            ColorMode::TrueColor
118        );
119    }
120
121    #[test]
122    fn palette256_when_term_says_so() {
123        assert_eq!(
124            detect_color_from_env(env(&[("TERM", "screen-256color")])),
125            ColorMode::Palette256
126        );
127    }
128
129    #[test]
130    fn ansi16_for_plain_term() {
131        assert_eq!(
132            detect_color_from_env(env(&[("TERM", "xterm")])),
133            ColorMode::Ansi16
134        );
135    }
136
137    #[test]
138    fn empty_term_is_monochrome() {
139        assert_eq!(detect_color_from_env(env(&[])), ColorMode::Monochrome);
140    }
141}