Skip to main content

vtcode_commons/
ansi_capabilities.rs

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