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            && let Ok(bg) = bg_str.parse::<u8>()
137        {
138            return if bg == 7 || bg == 15 {
139                ColorScheme::Light
140            } else if bg == 0 || bg == 8 {
141                ColorScheme::Dark
142            } else if bg > 230 {
143                ColorScheme::Light
144            } else {
145                ColorScheme::Dark
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        && let Ok(term_program) = std::env::var("TERM_PROGRAM")
163        && term_program == "Apple_Terminal"
164    {
165        return ColorScheme::Light;
166    }
167
168    ColorScheme::Unknown
169}
170
171// Cache detection results to avoid repeated system calls
172static COLOR_DEPTH_CACHE: AtomicU8 = AtomicU8::new(255); // 255 = not cached yet
173
174/// Detect the terminal's color depth
175fn detect_color_depth() -> ColorDepth {
176    let cached = COLOR_DEPTH_CACHE.load(Ordering::Relaxed);
177    if cached != 255 {
178        return match cached {
179            0 => ColorDepth::None,
180            1 => ColorDepth::Basic16,
181            2 => ColorDepth::Color256,
182            3 => ColorDepth::TrueColor,
183            _ => ColorDepth::None,
184        };
185    }
186
187    let depth = if no_color() {
188        ColorDepth::None
189    } else if clicolor_force() {
190        ColorDepth::TrueColor
191    } else if !clicolor().unwrap_or_else(term_supports_color) {
192        ColorDepth::None
193    } else {
194        std::env::var("COLORTERM")
195            .ok()
196            .and_then(|val| {
197                let lower = val.to_lowercase();
198                if lower.contains("truecolor") || lower.contains("24bit") {
199                    Some(ColorDepth::TrueColor)
200                } else {
201                    None
202                }
203            })
204            .unwrap_or(ColorDepth::Color256)
205    };
206
207    COLOR_DEPTH_CACHE.store(
208        match depth {
209            ColorDepth::None => 0,
210            ColorDepth::Basic16 => 1,
211            ColorDepth::Color256 => 2,
212            ColorDepth::TrueColor => 3,
213        },
214        Ordering::Relaxed,
215    );
216
217    depth
218}
219
220/// Detect if unicode is supported by the terminal
221fn detect_unicode_support() -> bool {
222    std::env::var("LANG")
223        .ok()
224        .map(|lang| lang.to_lowercase().contains("utf"))
225        .or_else(|| {
226            std::env::var("LC_ALL")
227                .ok()
228                .map(|lc| lc.to_lowercase().contains("utf"))
229        })
230        .unwrap_or(true)
231}
232
233/// Global capabilities instance (cached)
234pub static CAPABILITIES: Lazy<AnsiCapabilities> = Lazy::new(AnsiCapabilities::detect);
235
236/// Check if NO_COLOR environment variable is set
237pub fn is_no_color() -> bool {
238    no_color()
239}
240
241/// Check if CLICOLOR_FORCE is set
242pub fn is_clicolor_force() -> bool {
243    clicolor_force()
244}