Skip to main content

tui_nuances/
support.rs

1//! Terminal color capability detection and [`Theme`] downgrade.
2
3use crate::color::{to_ansi16, to_ansi256};
4use crate::theme::Theme;
5use ratatui::style::Color;
6
7/// How many colors the terminal can show.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub enum ColorSupport {
10    /// No colors at all (`NO_COLOR` set or `TERM=dumb`).
11    None,
12    /// The 16 basic ANSI colors.
13    Ansi16,
14    /// The xterm 256-color palette.
15    Ansi256,
16    /// 24-bit RGB.
17    TrueColor,
18}
19
20impl ColorSupport {
21    /// Detect support from the process environment (`NO_COLOR`, `COLORTERM`, `TERM`).
22    #[must_use]
23    pub fn detect() -> Self {
24        let get = |k: &str| std::env::var(k).ok();
25        Self::from_env(
26            get("NO_COLOR").as_deref(),
27            get("COLORTERM").as_deref(),
28            get("TERM").as_deref(),
29        )
30    }
31
32    /// Pure detection logic behind [`ColorSupport::detect`], for testing and
33    /// for apps that read the environment themselves.
34    ///
35    /// * a non-empty `no_color` → [`ColorSupport::None`] (<https://no-color.org>)
36    /// * `COLORTERM` of `truecolor`/`24bit`, or `TERM` containing `direct` → true color
37    /// * `TERM` containing `256color` → 256 colors
38    /// * `TERM` of `dumb` → none; anything else → 16 colors
39    #[must_use]
40    pub fn from_env(no_color: Option<&str>, colorterm: Option<&str>, term: Option<&str>) -> Self {
41        if no_color.is_some_and(|v| !v.is_empty()) {
42            return Self::None;
43        }
44        let colorterm = colorterm.unwrap_or("").to_ascii_lowercase();
45        let term = term.unwrap_or("").to_ascii_lowercase();
46        if colorterm.contains("truecolor") || colorterm.contains("24bit") || term.contains("direct")
47        {
48            Self::TrueColor
49        } else if term.contains("256color") {
50            Self::Ansi256
51        } else if term == "dumb" {
52            Self::None
53        } else {
54            Self::Ansi16
55        }
56    }
57
58    /// Reduce one color to what this support level can show.
59    #[must_use]
60    pub fn downgrade_color(self, color: Color) -> Color {
61        match self {
62            Self::TrueColor => color,
63            Self::Ansi256 => to_ansi256(color),
64            Self::Ansi16 => to_ansi16(color),
65            Self::None => Color::Reset,
66        }
67    }
68}
69
70impl Theme {
71    /// Reduce every color of the theme to what `support` can display.
72    ///
73    /// `TrueColor` is a no-op, `Ansi256` maps RGB to [`Color::Indexed`],
74    /// `Ansi16` maps everything to the 16 named colors and `None` resets all
75    /// colors (and clears background/surface) so the terminal default is used.
76    #[must_use]
77    pub fn downgrade(&self, support: ColorSupport) -> Theme {
78        let mut t = self.clone();
79        t.map_colors(|c| support.downgrade_color(c));
80        if support == ColorSupport::None {
81            t.background = None;
82            t.surface = None;
83        }
84        t
85    }
86
87    /// [`Theme::downgrade`] using [`ColorSupport::detect`].
88    #[must_use]
89    pub fn downgrade_for_terminal(&self) -> Theme {
90        self.downgrade(ColorSupport::detect())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn no_color_wins() {
100        let s = ColorSupport::from_env(Some("1"), Some("truecolor"), Some("xterm-256color"));
101        assert_eq!(s, ColorSupport::None);
102    }
103
104    #[test]
105    fn empty_no_color_ignored() {
106        let s = ColorSupport::from_env(Some(""), Some("truecolor"), None);
107        assert_eq!(s, ColorSupport::TrueColor);
108    }
109
110    #[test]
111    fn colorterm_variants() {
112        assert_eq!(
113            ColorSupport::from_env(None, Some("24bit"), None),
114            ColorSupport::TrueColor
115        );
116        assert_eq!(
117            ColorSupport::from_env(None, Some("TrueColor"), Some("xterm")),
118            ColorSupport::TrueColor
119        );
120    }
121
122    #[test]
123    fn term_variants() {
124        let f = |t| ColorSupport::from_env(None, None, Some(t));
125        assert_eq!(f("xterm-256color"), ColorSupport::Ansi256);
126        assert_eq!(f("xterm-direct"), ColorSupport::TrueColor);
127        assert_eq!(f("dumb"), ColorSupport::None);
128        assert_eq!(f("xterm"), ColorSupport::Ansi16);
129        assert_eq!(
130            ColorSupport::from_env(None, None, None),
131            ColorSupport::Ansi16
132        );
133    }
134
135    #[test]
136    fn ordering_by_capability() {
137        assert!(ColorSupport::None < ColorSupport::Ansi16);
138        assert!(ColorSupport::Ansi256 < ColorSupport::TrueColor);
139    }
140
141    #[test]
142    fn downgrade_color_levels() {
143        let c = Color::Rgb(255, 0, 0);
144        assert_eq!(ColorSupport::TrueColor.downgrade_color(c), c);
145        assert_eq!(
146            ColorSupport::Ansi256.downgrade_color(c),
147            Color::Indexed(196)
148        );
149        assert_eq!(ColorSupport::Ansi16.downgrade_color(c), Color::LightRed);
150        assert_eq!(ColorSupport::None.downgrade_color(c), Color::Reset);
151    }
152
153    #[test]
154    fn truecolor_downgrade_is_identity() {
155        let t = Theme::dracula();
156        assert_eq!(t.downgrade(ColorSupport::TrueColor), t);
157    }
158
159    #[test]
160    fn none_clears_backgrounds() {
161        let t = Theme::dracula().downgrade(ColorSupport::None);
162        assert_eq!(t.background, None);
163        assert_eq!(t.surface, None);
164        assert_eq!(t.foreground, Color::Reset);
165    }
166}