Skip to main content

vtcode_commons/
ansi_capabilities.rs

1//! ANSI terminal capabilities detection and feature support
2
3use anstyle_query::{clicolor, clicolor_force, no_color, term_supports_color};
4use once_cell::sync::Lazy;
5use std::sync::atomic::{AtomicU8, Ordering};
6
7/// Color depth support level detected for the terminal
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9pub enum ColorDepth {
10    /// No color support
11    None = 0,
12    /// 16 colors (basic ANSI)
13    Basic16 = 1,
14    /// 256 colors
15    Color256 = 2,
16    /// True color (24-bit RGB)
17    TrueColor = 3,
18}
19
20impl ColorDepth {
21    /// Get a human-readable name for this color depth
22    pub fn name(self) -> &'static str {
23        match self {
24            ColorDepth::None => "none",
25            ColorDepth::Basic16 => "16-color",
26            ColorDepth::Color256 => "256-color",
27            ColorDepth::TrueColor => "true-color",
28        }
29    }
30
31    /// Check if this depth supports color
32    pub fn supports_color(self) -> bool {
33        self != ColorDepth::None
34    }
35
36    /// Check if this depth is at least 256 colors
37    pub fn supports_256(self) -> bool {
38        self >= ColorDepth::Color256
39    }
40
41    /// Check if this depth supports true color
42    pub fn supports_true_color(self) -> bool {
43        self == ColorDepth::TrueColor
44    }
45}
46
47/// ANSI terminal feature capabilities
48#[derive(Clone, Copy, Debug)]
49pub struct AnsiCapabilities {
50    /// Detected color depth
51    pub color_depth: ColorDepth,
52    /// Whether unicode is supported
53    pub unicode_support: bool,
54    /// Whether to force color output
55    pub force_color: bool,
56    /// Whether color is explicitly disabled
57    pub no_color: bool,
58}
59
60impl AnsiCapabilities {
61    /// Detect terminal capabilities
62    pub fn detect() -> Self {
63        Self {
64            color_depth: detect_color_depth(),
65            unicode_support: detect_unicode_support(),
66            force_color: clicolor_force(),
67            no_color: no_color(),
68        }
69    }
70
71    /// Check if color output is supported
72    pub fn supports_color(&self) -> bool {
73        !self.no_color && (self.force_color || self.color_depth.supports_color())
74    }
75
76    /// Check if 256-color output is supported
77    pub fn supports_256_colors(&self) -> bool {
78        self.supports_color() && self.color_depth.supports_256()
79    }
80
81    /// Check if true color (24-bit) is supported
82    pub fn supports_true_color(&self) -> bool {
83        self.supports_color() && self.color_depth.supports_true_color()
84    }
85
86    /// Check if advanced formatting (tables, boxes) should use unicode
87    pub fn should_use_unicode_boxes(&self) -> bool {
88        self.unicode_support && self.supports_color()
89    }
90}
91
92/// Detected terminal color scheme (light or dark background)
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
94pub enum ColorScheme {
95    /// Light background (dark text preferred)
96    Light,
97    /// Dark background (light text preferred)
98    #[default]
99    Dark,
100    /// Unable to detect, assume dark
101    Unknown,
102}
103
104impl ColorScheme {
105    /// Check if this is a light color scheme
106    pub fn is_light(self) -> bool {
107        matches!(self, ColorScheme::Light)
108    }
109
110    /// Check if this is a dark color scheme
111    pub fn is_dark(self) -> bool {
112        matches!(self, ColorScheme::Dark | ColorScheme::Unknown)
113    }
114
115    /// Get a human-readable name
116    pub fn name(self) -> &'static str {
117        match self {
118            ColorScheme::Light => "light",
119            ColorScheme::Dark => "dark",
120            ColorScheme::Unknown => "unknown",
121        }
122    }
123}
124
125/// Detect terminal color scheme from environment.
126pub fn detect_color_scheme() -> ColorScheme {
127    // Check cached value first
128    static CACHED: Lazy<ColorScheme> = Lazy::new(detect_color_scheme_uncached);
129    *CACHED
130}
131
132fn detect_color_scheme_uncached() -> ColorScheme {
133    if let Ok(colorfgbg) = std::env::var("COLORFGBG") {
134        let parts: Vec<&str> = colorfgbg.split(';').collect();
135        if let Some(bg_str) = parts.last() {
136            if let Ok(bg) = bg_str.parse::<u8>() {
137                return if bg == 7 || bg == 15 {
138                    ColorScheme::Light
139                } else if bg == 0 || bg == 8 {
140                    ColorScheme::Dark
141                } else if bg > 230 {
142                    ColorScheme::Light
143                } else {
144                    ColorScheme::Dark
145                };
146            }
147        }
148    }
149
150    if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
151        let term_lower = term_program.to_lowercase();
152        if term_lower.contains("iterm")
153            || term_lower.contains("ghostty")
154            || term_lower.contains("warp")
155            || term_lower.contains("alacritty")
156        {
157            return ColorScheme::Dark;
158        }
159    }
160
161    if cfg!(target_os = "macos") {
162        if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
163            if term_program == "Apple_Terminal" {
164                return ColorScheme::Light;
165            }
166        }
167    }
168
169    ColorScheme::Unknown
170}
171
172// Cache detection results to avoid repeated system calls
173static COLOR_DEPTH_CACHE: AtomicU8 = AtomicU8::new(255); // 255 = not cached yet
174
175/// Detect the terminal's color depth
176fn detect_color_depth() -> ColorDepth {
177    let cached = COLOR_DEPTH_CACHE.load(Ordering::Relaxed);
178    if cached != 255 {
179        return match cached {
180            0 => ColorDepth::None,
181            1 => ColorDepth::Basic16,
182            2 => ColorDepth::Color256,
183            3 => ColorDepth::TrueColor,
184            _ => ColorDepth::None,
185        };
186    }
187
188    let depth = if no_color() {
189        ColorDepth::None
190    } else if clicolor_force() {
191        ColorDepth::TrueColor
192    } else if !clicolor().unwrap_or_else(term_supports_color) {
193        ColorDepth::None
194    } else {
195        std::env::var("COLORTERM")
196            .ok()
197            .and_then(|val| {
198                let lower = val.to_lowercase();
199                if lower.contains("truecolor") || lower.contains("24bit") {
200                    Some(ColorDepth::TrueColor)
201                } else {
202                    None
203                }
204            })
205            .unwrap_or(ColorDepth::Color256)
206    };
207
208    COLOR_DEPTH_CACHE.store(
209        match depth {
210            ColorDepth::None => 0,
211            ColorDepth::Basic16 => 1,
212            ColorDepth::Color256 => 2,
213            ColorDepth::TrueColor => 3,
214        },
215        Ordering::Relaxed,
216    );
217
218    depth
219}
220
221/// Detect if unicode is supported by the terminal
222fn detect_unicode_support() -> bool {
223    std::env::var("LANG")
224        .ok()
225        .map(|lang| lang.to_lowercase().contains("utf"))
226        .or_else(|| {
227            std::env::var("LC_ALL")
228                .ok()
229                .map(|lc| lc.to_lowercase().contains("utf"))
230        })
231        .unwrap_or(true)
232}
233
234/// Global capabilities instance (cached)
235pub static CAPABILITIES: Lazy<AnsiCapabilities> = Lazy::new(AnsiCapabilities::detect);
236
237/// Check if NO_COLOR environment variable is set
238pub fn is_no_color() -> bool {
239    no_color()
240}
241
242/// Check if CLICOLOR_FORCE is set
243pub fn is_clicolor_force() -> bool {
244    clicolor_force()
245}