Skip to main content

supercode_frontend_tui/terminal/
palette.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/terminal_palette.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Deterministic terminal color degradation without donor runtime detection.
8
9use ratatui::style::Color;
10
11use crate::foundation::color::perceptual_distance;
12
13/// Color fidelity the active terminal can display safely.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum ColorLevel {
16    /// 24-bit RGB colors.
17    TrueColor,
18    /// Xterm's stable 240-color cube and grayscale range.
19    Ansi256,
20    /// No authored colors; use the terminal defaults.
21    Monochrome,
22}
23
24/// Explicit inputs used to resolve terminal color behavior.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct ColorCapabilities {
27    pub level: ColorLevel,
28    pub color_enabled: bool,
29}
30
31impl ColorCapabilities {
32    /// Resolve capabilities from environment values supplied by the caller.
33    ///
34    /// Passing values explicitly keeps snapshots deterministic and avoids
35    /// mutating process-global environment variables in parallel tests.
36    pub fn resolve(
37        no_color: bool,
38        term: Option<&str>,
39        colorterm: Option<&str>,
40        force_color: bool,
41    ) -> Self {
42        if no_color && !force_color {
43            return Self::monochrome();
44        }
45        if term.is_some_and(|value| value.eq_ignore_ascii_case("dumb")) && !force_color {
46            return Self::monochrome();
47        }
48
49        let truecolor = colorterm.is_some_and(|value| {
50            value.eq_ignore_ascii_case("truecolor") || value.eq_ignore_ascii_case("24bit")
51        });
52        let ansi256 = term.is_some_and(|value| value.to_ascii_lowercase().contains("256color"));
53        let level = if truecolor {
54            ColorLevel::TrueColor
55        } else if ansi256 || force_color {
56            ColorLevel::Ansi256
57        } else {
58            ColorLevel::Monochrome
59        };
60        Self {
61            level,
62            color_enabled: level != ColorLevel::Monochrome,
63        }
64    }
65
66    pub const fn monochrome() -> Self {
67        Self {
68            level: ColorLevel::Monochrome,
69            color_enabled: false,
70        }
71    }
72
73    /// Return the closest safe Ratatui color for these capabilities.
74    pub fn best_color(self, target: (u8, u8, u8)) -> Color {
75        match self.level {
76            ColorLevel::TrueColor => Color::Rgb(target.0, target.1, target.2),
77            ColorLevel::Ansi256 => Color::Indexed(nearest_xterm_index(target)),
78            ColorLevel::Monochrome => Color::Reset,
79        }
80    }
81}
82
83fn nearest_xterm_index(target: (u8, u8, u8)) -> u8 {
84    xterm_fixed_colors()
85        .min_by(|(_, left), (_, right)| {
86            perceptual_distance(*left, target)
87                .partial_cmp(&perceptual_distance(*right, target))
88                .unwrap_or(std::cmp::Ordering::Equal)
89        })
90        .map_or(16, |(index, _)| index)
91}
92
93fn xterm_fixed_colors() -> impl Iterator<Item = (u8, (u8, u8, u8))> {
94    let cube = (0u8..6).flat_map(|r| {
95        (0u8..6).flat_map(move |g| {
96            (0u8..6).map(move |b| {
97                let index = 16 + 36 * r + 6 * g + b;
98                (
99                    index,
100                    (xterm_component(r), xterm_component(g), xterm_component(b)),
101                )
102            })
103        })
104    });
105    let grayscale = (0u8..24).map(|offset| {
106        let value = 8 + offset * 10;
107        (232 + offset, (value, value, value))
108    });
109    cube.chain(grayscale)
110}
111
112const fn xterm_component(value: u8) -> u8 {
113    if value == 0 {
114        0
115    } else {
116        55 + value * 40
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn truecolor_is_not_quantized() {
126        let capabilities =
127            ColorCapabilities::resolve(false, Some("xterm-256color"), Some("truecolor"), false);
128        assert_eq!(capabilities.level, ColorLevel::TrueColor);
129        assert_eq!(
130            capabilities.best_color((12, 34, 56)),
131            Color::Rgb(12, 34, 56)
132        );
133    }
134
135    #[test]
136    fn ansi256_uses_a_stable_index() {
137        let capabilities = ColorCapabilities::resolve(false, Some("xterm-256color"), None, false);
138        assert_eq!(capabilities.level, ColorLevel::Ansi256);
139        assert_eq!(capabilities.best_color((255, 0, 0)), Color::Indexed(196));
140        assert_eq!(
141            capabilities.best_color((128, 128, 128)),
142            Color::Indexed(244)
143        );
144    }
145
146    #[test]
147    fn no_color_disables_authored_colors() {
148        let capabilities =
149            ColorCapabilities::resolve(true, Some("xterm-256color"), Some("truecolor"), false);
150        assert_eq!(capabilities, ColorCapabilities::monochrome());
151        assert_eq!(capabilities.best_color((255, 0, 0)), Color::Reset);
152    }
153
154    #[test]
155    fn dumb_terminal_is_monochrome_unless_forced() {
156        assert_eq!(
157            ColorCapabilities::resolve(false, Some("dumb"), None, false),
158            ColorCapabilities::monochrome()
159        );
160        assert_eq!(
161            ColorCapabilities::resolve(false, Some("dumb"), None, true).level,
162            ColorLevel::Ansi256
163        );
164    }
165}