Skip to main content

oxi_tui/render/
color_level.rs

1//! Terminal color capability detection and color downgrade utilities.
2//!
3//! Detects the terminal's supported color level (None/Basic/Ansi256/TrueColor)
4//! and provides conversions for downgrading RGB colors when the terminal
5//! cannot render 24-bit color.
6//!
7//! ## Design
8//!
9//! `detect_color_level()` is `LazyLock`-cached — the terminal capability is
10//! determined once on first call and reused. This avoids re-parsing env vars
11//! on every cell render.
12//!
13//! `adapt_color(color, level)` is a free function (not a method on ratatui's
14//! `Color` enum, since `cell::Color` is just a re-export of `ratatui::style::Color`
15//! and we cannot add inherent methods to external types).
16
17use std::sync::LazyLock;
18
19use crate::cell::Color;
20
21/// The level of color support detected for the terminal.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
23pub enum ColorLevel {
24    /// No color support (monochrome terminals, NO_COLOR env)
25    None,
26    /// Basic 16-color ANSI support (colors 0-15)
27    Basic,
28    /// 256-color support (colors 0-255)
29    Ansi256,
30    /// 24-bit truecolor RGB support (16 million colors)
31    #[default]
32    TrueColor,
33}
34
35impl ColorLevel {
36    /// Returns true if at least basic color is supported.
37    pub fn has_color(self) -> bool {
38        self >= Self::Basic
39    }
40
41    /// Returns true if 256-color mode is supported.
42    pub fn has_256(self) -> bool {
43        self >= Self::Ansi256
44    }
45
46    /// Returns true if 24-bit truecolor is supported.
47    pub fn has_truecolor(self) -> bool {
48        self >= Self::TrueColor
49    }
50}
51
52static COLOR_LEVEL: LazyLock<ColorLevel> = LazyLock::new(detect_color_level_inner);
53
54/// Detect the terminal's color support level. Cached after first call.
55///
56/// Respects `NO_COLOR` env (<https://no-color.org/>) — returns `ColorLevel::None`.
57/// Checks `COLORTERM`, `TERM`, and terminal-specific env vars via the
58/// `supports-color` crate. Recovers truecolor when tmux/SSH/mosh strip
59/// `COLORTERM` by recognizing known truecolor terminals (iTerm2, WezTerm,
60/// Ghostty, Kitty, etc.) via their env identifiers.
61pub fn detect_color_level() -> ColorLevel {
62    *COLOR_LEVEL
63}
64
65fn detect_color_level_inner() -> ColorLevel {
66    // Explicit opt-out via NO_COLOR takes priority per the spec.
67    if std::env::var_os("NO_COLOR").is_some() {
68        return ColorLevel::None;
69    }
70
71    let level = match supports_color::on(supports_color::Stream::Stdout) {
72        // Not a TTY (tests, piped) — default to TrueColor.
73        // oxi-tui is a widget library; the actual TUI runtime decides.
74        None => ColorLevel::TrueColor,
75        Some(level) => {
76            if level.has_16m {
77                ColorLevel::TrueColor
78            } else if level.has_256 {
79                ColorLevel::Ansi256
80            } else if level.has_basic {
81                ColorLevel::Basic
82            } else {
83                ColorLevel::None
84            }
85        }
86    };
87
88    // The `supports-color` crate relies on COLORTERM=truecolor, but
89    // tmux/SSH/mosh often strip that variable. When the crate reports
90    // only 256-color support, upgrade to TrueColor if we can identify
91    // a known truecolor-capable terminal via its env vars.
92    if level < ColorLevel::TrueColor && terminal_supports_truecolor() {
93        return ColorLevel::TrueColor;
94    }
95
96    level
97}
98
99/// Recognize known truecolor-capable terminals by their env identifiers.
100///
101/// This is a fallback for when `COLORTERM` is stripped by tmux/SSH/mosh.
102/// We don't claim TrueColor for unknown terminals — the user can override
103/// via `COLORTERM=truecolor` if their terminal is unrecognized.
104fn terminal_supports_truecolor() -> bool {
105    const TRUECOLOR_TERMINALS: &[&str] = &["WezTerm", "ghostty", "kitty", "rio", "tabby", "vscode"];
106
107    // ITERM_SESSION_ID is set by iTerm2 (and only iTerm2).
108    if std::env::var_os("ITERM_SESSION_ID").is_some() {
109        return true;
110    }
111    // Ghostty sets GHOSTTY_RESOURCES_DIR.
112    if std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some() {
113        return true;
114    }
115    // TERM_PROGRAM is set by many modern terminals.
116    if let Some(term_program) = std::env::var_os("TERM_PROGRAM")
117        && let Some(s) = term_program.to_str()
118        && TRUECOLOR_TERMINALS.contains(&s)
119    {
120        return true;
121    }
122    false
123}
124
125/// Map an RGB color to the nearest xterm 256-color cube entry.
126///
127/// Uses the standard xterm 216-cube + 24 grayscale ramp. Returns the
128/// Ansi256 index (0-255).
129pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
130    // Logic mirrors anstyle's algorithm for deterministic output.
131    if r == g && g == b {
132        // Grayscale: 24-step ramp from index 232 (blackest) to 255 (whitest).
133        if r < 8 {
134            return 16;
135        }
136        if r > 248 {
137            return 231;
138        }
139        // Map r in [8, 248] to grayscale index 0..23, then offset to 232.
140        return ((r as u16 - 8) * 24 / 240) as u8 + 232;
141    }
142    // 6x6x6 cube starting at index 16.
143    let cube = |c: u8| -> u8 {
144        if c < 48 {
145            0
146        } else if c < 115 {
147            1
148        } else {
149            (c as u16 * 5 / 255) as u8
150        }
151    };
152    16 + 36 * cube(r) + 6 * cube(g) + cube(b)
153}
154
155/// Downgrade a 256-color index to the nearest 16-color ANSI basic color.
156pub fn ansi256_to_basic(idx: u8) -> u8 {
157    match idx {
158        0..=7 => idx,    // Already basic
159        8 => 0,          // Bright black → black
160        9..=15 => idx,   // Already bright basic
161        16 => 0,         // Black
162        17..=21 => 4,    // Blue range
163        22..=27 => 2,    // Green range
164        28..=51 => 2,    // Green-cyan
165        52..=87 => 3,    // Yellow range
166        88..=123 => 1,   // Red range
167        124..=159 => 1,  // Magenta range
168        160..=195 => 3,  // Yellow-cyan
169        196..=231 => 9,  // Bright red range (high intensity)
170        232..=237 => 0,  // Dark grays → black
171        238..=243 => 8,  // Mid grays → bright black
172        244..=249 => 7,  // Light-mid grays → white
173        250..=255 => 15, // Light grays → bright white
174    }
175}
176
177/// Downgrade a ratatui `Color` to fit the terminal's color level.
178///
179/// At `TrueColor`: passthrough (no downgrade).
180/// At `Ansi256`: `Rgb(r,g,b)` → `Indexed(rgb_to_ansi256(...))`; `Indexed` passthrough.
181/// At `Basic`: further downgrades `Indexed` to the nearest of 16 basic ANSI colors.
182/// At `None`: any color becomes `Reset` (monochrome).
183///
184/// Named colors (`Color::Red`, `Color::Blue`, etc.) stay as-is at Basic+,
185/// since ratatui already maps them to the basic 16. At `None`, they become `Reset`.
186pub fn adapt_color(color: Color, level: ColorLevel) -> Color {
187    match (color, level) {
188        (_, ColorLevel::None) => Color::Reset,
189        (Color::Reset, _) => Color::Reset,
190        (c, ColorLevel::TrueColor) => c,
191        (Color::Rgb(r, g, b), ColorLevel::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
192        (Color::Rgb(r, g, b), ColorLevel::Basic) => {
193            Color::Indexed(ansi256_to_basic(rgb_to_ansi256(r, g, b)))
194        }
195        (c @ Color::Indexed(_), ColorLevel::Ansi256) => c,
196        (Color::Indexed(idx), ColorLevel::Basic) => {
197            if idx < 16 {
198                Color::Indexed(idx) // Already basic
199            } else {
200                Color::Indexed(ansi256_to_basic(idx))
201            }
202        }
203        // Named colors (Black, Red, Green, Yellow, Blue, Magenta, Cyan, Gray,
204        // DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta,
205        // LightCyan, White) are already basic 16 — passthrough.
206        (c, ColorLevel::Basic | ColorLevel::Ansi256) => c,
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_color_level_ordering() {
216        assert!(ColorLevel::TrueColor > ColorLevel::Ansi256);
217        assert!(ColorLevel::Ansi256 > ColorLevel::Basic);
218        assert!(ColorLevel::Basic > ColorLevel::None);
219    }
220
221    #[test]
222    fn test_color_level_predicates() {
223        assert!(ColorLevel::TrueColor.has_truecolor());
224        assert!(ColorLevel::TrueColor.has_256());
225        assert!(ColorLevel::TrueColor.has_color());
226
227        assert!(!ColorLevel::Ansi256.has_truecolor());
228        assert!(ColorLevel::Ansi256.has_256());
229        assert!(ColorLevel::Ansi256.has_color());
230
231        assert!(!ColorLevel::Basic.has_truecolor());
232        assert!(!ColorLevel::Basic.has_256());
233        assert!(ColorLevel::Basic.has_color());
234
235        assert!(!ColorLevel::None.has_color());
236    }
237
238    #[test]
239    fn test_detect_caches_after_first_call() {
240        // LazyLock guarantees a single init. Two calls return the same value.
241        let first = detect_color_level();
242        let second = detect_color_level();
243        assert_eq!(first, second);
244    }
245
246    #[test]
247    fn test_rgb_to_ansi256_pure_red() {
248        // Pure red (255, 0, 0) maps to cube corner 196.
249        assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
250    }
251
252    #[test]
253    fn test_rgb_to_ansi256_pure_green() {
254        // Pure green (0, 255, 0) maps to cube corner 46.
255        assert_eq!(rgb_to_ansi256(0, 255, 0), 46);
256    }
257
258    #[test]
259    fn test_rgb_to_ansi256_pure_blue() {
260        // Pure blue (0, 0, 255) maps to cube corner 21.
261        assert_eq!(rgb_to_ansi256(0, 0, 255), 21);
262    }
263
264    #[test]
265    fn test_rgb_to_ansi256_black() {
266        // Black is the bottom of grayscale ramp → 16.
267        assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
268    }
269
270    #[test]
271    fn test_rgb_to_ansi256_white() {
272        // White (255,255,255) is the top of grayscale ramp → 231.
273        assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
274    }
275
276    #[test]
277    fn test_rgb_to_ansi256_midgray() {
278        // (128, 128, 128) is grayscale → 244 (mid of ramp).
279        let idx = rgb_to_ansi256(128, 128, 128);
280        assert!((232..=255).contains(&idx));
281    }
282
283    #[test]
284    fn test_ansi256_to_basic_corners() {
285        // Index 0 (black) stays 0.
286        assert_eq!(ansi256_to_basic(0), 0);
287        // Index 196 (bright red) → 9 (bright red basic).
288        assert_eq!(ansi256_to_basic(196), 9);
289        // Index 46 (bright green) → falls in 28..=51 range → 2 (green).
290        assert_eq!(ansi256_to_basic(46), 2);
291        // Index 21 (blue) → 4.
292        assert_eq!(ansi256_to_basic(21), 4);
293        // Index 255 (bright white) → 15.
294        assert_eq!(ansi256_to_basic(255), 15);
295    }
296
297    #[test]
298    fn test_terminal_supports_truecolor_iterm() {
299        // SAFETY: tests are single-threaded when run with --test-threads=1.
300        // The env var is restored after the assertion.
301        unsafe {
302            std::env::set_var("ITERM_SESSION_ID", "test:value");
303        }
304        assert!(terminal_supports_truecolor());
305        unsafe {
306            std::env::remove_var("ITERM_SESSION_ID");
307        }
308    }
309
310    #[test]
311    fn test_terminal_does_not_support_truecolor_unknown() {
312        // SAFETY: tests are single-threaded when run with --test-threads=1.
313        unsafe {
314            std::env::remove_var("ITERM_SESSION_ID");
315            std::env::remove_var("GHOSTTY_RESOURCES_DIR");
316            std::env::remove_var("TERM_PROGRAM");
317        }
318        assert!(!terminal_supports_truecolor());
319    }
320
321    #[test]
322    fn test_adapt_color_truecolor_passes_through_rgb() {
323        // TrueColor terminal: RGB stays as RGB.
324        let c = Color::Rgb(123, 45, 67);
325        assert_eq!(adapt_color(c, ColorLevel::TrueColor), c);
326    }
327
328    #[test]
329    fn test_adapt_color_ansi256_downgrades_rgb() {
330        // Ansi256 terminal: RGB downgrades to Indexed.
331        let c = Color::Rgb(255, 0, 0);
332        let adapted = adapt_color(c, ColorLevel::Ansi256);
333        assert_eq!(adapted, Color::Indexed(196));
334    }
335
336    #[test]
337    fn test_adapt_color_basic_downgrades_rgb_to_basic() {
338        // Basic terminal: RGB downgrades to basic named color (still Indexed).
339        let c = Color::Rgb(255, 0, 0);
340        let adapted = adapt_color(c, ColorLevel::Basic);
341        // Should be one of the basic 16 colors (Indexed < 16).
342        match adapted {
343            Color::Indexed(idx) => assert!(idx < 16, "expected basic color, got {idx}"),
344            other => panic!("expected Indexed, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn test_adapt_color_none_returns_reset() {
350        // Monochrome terminal: any color becomes Reset.
351        let c = Color::Rgb(123, 45, 67);
352        assert_eq!(adapt_color(c, ColorLevel::None), Color::Reset);
353    }
354
355    #[test]
356    fn test_adapt_color_named_stays_named() {
357        // Named colors (Color::Red etc) stay as-is at Basic and above.
358        let c = Color::Red;
359        assert_eq!(adapt_color(c, ColorLevel::Basic), c);
360        assert_eq!(adapt_color(c, ColorLevel::Ansi256), c);
361        assert_eq!(adapt_color(c, ColorLevel::TrueColor), c);
362    }
363
364    #[test]
365    fn test_adapt_color_none_named_to_reset() {
366        // Monochrome: even named colors become Reset.
367        assert_eq!(adapt_color(Color::Red, ColorLevel::None), Color::Reset);
368    }
369
370    #[test]
371    fn test_adapt_color_reset_always_reset() {
372        // Reset stays Reset at any level.
373        assert_eq!(adapt_color(Color::Reset, ColorLevel::None), Color::Reset);
374        assert_eq!(
375            adapt_color(Color::Reset, ColorLevel::TrueColor),
376            Color::Reset
377        );
378    }
379
380    #[test]
381    fn test_adapt_color_ansi256_indexed_stays_at_ansi256() {
382        // Indexed(196) at Ansi256 level stays as Indexed(196).
383        let c = Color::Indexed(196);
384        assert_eq!(adapt_color(c, ColorLevel::Ansi256), c);
385    }
386
387    #[test]
388    fn test_adapt_color_ansi256_indexed_downgrades_at_basic() {
389        // Indexed(196) at Basic level → Indexed(9) (bright red basic).
390        let c = Color::Indexed(196);
391        assert_eq!(adapt_color(c, ColorLevel::Basic), Color::Indexed(9));
392    }
393}