Skip to main content

codetether_agent/tui/
theme_utils.rs

1//! Theme utilities for color support detection and validation
2
3use crate::tui::theme::{ColorDef, Theme};
4
5/// Check terminal color support via environment detection
6pub fn detect_color_support() -> ColorSupport {
7    // Check COLORTERM for truecolor
8    if let Ok(colorterm) = std::env::var("COLORTERM") {
9        if colorterm == "truecolor" || colorterm == "24bit" {
10            return ColorSupport::TrueColor;
11        }
12    }
13
14    // Check TERM for 256 color support
15    if let Ok(term) = std::env::var("TERM") {
16        if term.contains("256color") || term.contains("256") {
17            return ColorSupport::Ansi256;
18        }
19        if term.contains("color") || term.starts_with("xterm") || term.starts_with("screen") {
20            return ColorSupport::Ansi8;
21        }
22    }
23
24    // Default to 8 colors (most common minimum)
25    ColorSupport::Ansi8
26}
27
28/// Color support levels
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[allow(dead_code)]
31pub enum ColorSupport {
32    Monochrome,
33    Ansi8,
34    Ansi256,
35    TrueColor,
36}
37
38impl ColorSupport {
39    pub fn supports_rgb(&self) -> bool {
40        matches!(self, ColorSupport::TrueColor)
41    }
42
43    pub fn supports_indexed(&self) -> bool {
44        matches!(self, ColorSupport::Ansi256 | ColorSupport::TrueColor)
45    }
46
47    pub fn supports_named(&self) -> bool {
48        !matches!(self, ColorSupport::Monochrome)
49    }
50}
51
52/// Validate and adjust theme based on terminal capabilities
53pub fn validate_theme(theme: &Theme) -> Theme {
54    let support = detect_color_support();
55    let mut validated = theme.clone();
56
57    if !support.supports_rgb() {
58        // Convert RGB colors to closest named colors
59        validated.user_color = fallback_color(&theme.user_color, &support);
60        validated.assistant_color = fallback_color(&theme.assistant_color, &support);
61        validated.system_color = fallback_color(&theme.system_color, &support);
62        validated.tool_color = fallback_color(&theme.tool_color, &support);
63        validated.error_color = fallback_color(&theme.error_color, &support);
64        validated.border_color = fallback_color(&theme.border_color, &support);
65        validated.input_border_color = fallback_color(&theme.input_border_color, &support);
66        validated.help_border_color = fallback_color(&theme.help_border_color, &support);
67        validated.timestamp_color = fallback_color(&theme.timestamp_color, &support);
68        validated.code_block_color = fallback_color(&theme.code_block_color, &support);
69        validated.status_bar_foreground = fallback_color(&theme.status_bar_foreground, &support);
70        validated.status_bar_background = fallback_color(&theme.status_bar_background, &support);
71
72        if let Some(bg) = &theme.background {
73            validated.background = Some(fallback_color(bg, &support));
74        }
75    }
76
77    validated
78}
79
80/// Convert colors to fallback based on terminal support
81fn fallback_color(color: &ColorDef, support: &ColorSupport) -> ColorDef {
82    match color {
83        ColorDef::Rgb(r, g, b) => {
84            if support.supports_rgb() {
85                ColorDef::Rgb(*r, *g, *b)
86            } else if support.supports_indexed() {
87                // Convert to closest indexed color
88                ColorDef::Indexed(rgb_to_ansi256(*r, *g, *b))
89            } else {
90                // Convert to closest named color
91                ColorDef::Named(rgb_to_named(*r, *g, *b))
92            }
93        }
94        ColorDef::Indexed(idx) => {
95            if support.supports_indexed() {
96                ColorDef::Indexed(*idx)
97            } else {
98                // Convert to named color
99                ColorDef::Named(indexed_to_named(*idx))
100            }
101        }
102        ColorDef::Named(name) => {
103            if support.supports_named() {
104                ColorDef::Named(name.clone())
105            } else {
106                // Fallback to safe defaults
107                ColorDef::Named("white".to_string())
108            }
109        }
110    }
111}
112
113/// Convert RGB to closest ANSI 256-color index
114fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
115    // Standard 256-color conversion
116    if r == g && g == b {
117        // Grayscale
118        if r < 8 {
119            16
120        } else if r > 248 {
121            231
122        } else {
123            232 + ((r - 8) / 10)
124        }
125    } else {
126        // Color
127        16 + (36 * (r / 51)) + (6 * (g / 51)) + (b / 51)
128    }
129}
130
131/// Convert RGB to closest named color
132fn rgb_to_named(r: u8, g: u8, b: u8) -> String {
133    // Simple mapping to standard 8 colors
134    let colors = [
135        ("black", (0, 0, 0)),
136        ("red", (128, 0, 0)),
137        ("green", (0, 128, 0)),
138        ("yellow", (128, 128, 0)),
139        ("blue", (0, 0, 128)),
140        ("magenta", (128, 0, 128)),
141        ("cyan", (0, 128, 128)),
142        ("white", (192, 192, 192)),
143    ];
144
145    let mut closest = "white";
146    let mut min_distance = u32::MAX;
147
148    for (name, (cr, cg, cb)) in colors.iter() {
149        let dr = (r as i32 - *cr as i32).unsigned_abs();
150        let dg = (g as i32 - *cg as i32).unsigned_abs();
151        let db = (b as i32 - *cb as i32).unsigned_abs();
152        let distance = dr * dr + dg * dg + db * db;
153
154        if distance < min_distance {
155            min_distance = distance;
156            closest = *name;
157        }
158    }
159
160    closest.to_string()
161}
162
163/// Convert indexed color to named color
164fn indexed_to_named(index: u8) -> String {
165    match index {
166        0..=15 => {
167            // Standard 16 colors
168            match index {
169                0 => "black",
170                1 => "red",
171                2 => "green",
172                3 => "yellow",
173                4 => "blue",
174                5 => "magenta",
175                6 => "cyan",
176                7 => "white",
177                8 => "darkgray",
178                9 => "lightred",
179                10 => "lightgreen",
180                11 => "lightyellow",
181                12 => "lightblue",
182                13 => "lightmagenta",
183                14 => "lightcyan",
184                15 => "lightgray",
185                _ => "white",
186            }
187        }
188        _ => "white",
189    }
190    .to_string()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_color_support_detection() {
199        let support = detect_color_support();
200        // Just ensure it doesn't panic
201        assert!(matches!(
202            support,
203            ColorSupport::Monochrome
204                | ColorSupport::Ansi8
205                | ColorSupport::Ansi256
206                | ColorSupport::TrueColor
207        ));
208    }
209
210    #[test]
211    fn test_fallback_color() {
212        let support = ColorSupport::Ansi8;
213        let rgb_color = ColorDef::Rgb(255, 0, 0);
214        let fallback = fallback_color(&rgb_color, &support);
215        assert!(matches!(fallback, ColorDef::Named(_)));
216    }
217}