Skip to main content

oxi_tui/theme/capability/
mod.rs

1//! Terminal capability detection + theme color-level adaptation.
2//!
3//! ★ CRITICAL: detection and consumption live in the SAME MODULE.
4//! This is the structural fix for the dead-code class exemplified by
5//! legacy `render/color_level.rs` (394 LOC, re-exported, never called).
6//! See spec §1.5, §7.2.
7//!
8//! Per-kind detection logic lives in `detect.rs` (split to keep this
9//! module under the 500-LOC cap).
10
11mod detect;
12
13use ratatui::style::Color;
14
15use crate::theme::{ColorScheme, Theme};
16
17/// Supported image protocols for inline terminal images.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ImageProtocol {
20    /// Kitty image protocol (supported by Kitty, Ghostty, `WezTerm`).
21    Kitty,
22    /// iTerm2 inline image protocol (supported by iTerm2, `WezTerm`).
23    ITerm2,
24}
25
26/// Detected terminal color depth.
27///
28/// Ordered: `None < Basic < Ansi256 < TrueColor`. Comparison answers
29/// "is feature X available" via the `has_*` helpers below.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
31pub enum ColorLevel {
32    /// No color — terminal is monochrome / `NO_COLOR` was set.
33    #[default]
34    None,
35    /// 8-color basic ANSI.
36    Basic,
37    /// xterm 256-color palette.
38    Ansi256,
39    /// 24-bit true color (`Rgb(r,g,b)`).
40    TrueColor,
41}
42
43impl ColorLevel {
44    /// Any color (basic or better).
45    #[must_use]
46    pub fn has_color(self) -> bool {
47        self >= Self::Basic
48    }
49
50    /// 256-color or better.
51    #[must_use]
52    pub fn has_256(self) -> bool {
53        self >= Self::Ansi256
54    }
55
56    /// `TrueColor`.
57    #[must_use]
58    pub fn has_truecolor(self) -> bool {
59        self >= Self::TrueColor
60    }
61}
62/// All detected terminal capabilities. Populated once at bootstrap.
63#[allow(clippy::struct_excessive_bools)] // mirror per-kind capability flags
64#[derive(Debug, Clone)]
65pub struct TerminalCaps {
66    /// Detected color depth. Independent from per-kind booleans below
67    /// because a Kitty terminal under tmux may still degrade to Ansi256.
68    pub color_level: ColorLevel,
69    /// Supported image protocol, if any.
70    pub image_protocol: Option<ImageProtocol>,
71    /// Whether the terminal supports 24-bit true color.
72    pub true_color: bool,
73    /// Whether the terminal supports OSC 8 hyperlinks.
74    pub hyperlinks: bool,
75    /// Whether the Kitty keyboard protocol is active.
76    pub kitty_protocol: bool,
77    /// Supports Sixel inline images. Defaults to **false**.
78    pub sixel: bool,
79    /// Supports synchronized output (CSI 2026 BSU/ESU). Defaults to **true**
80    /// — harmless on unsupported terminals. Disable with `OXI_NO_SYNC_OUTPUT=1`.
81    pub synchronized_output: bool,
82    /// Supports Kitty's DECCARA rectangular background-fill extension.
83    /// Defaults to **false** — emitting DECCARA to an unsupported terminal
84    /// corrupts the display. Enabled for Kitty/Ghostty.
85    pub deccara: bool,
86    /// Cell size in pixels (width, height), if detectable.
87    pub cell_size: Option<(u16, u16)>,
88    /// Identified terminal family name, if recognized.
89    pub terminal_name: Option<String>,
90}
91
92impl Default for TerminalCaps {
93    fn default() -> Self {
94        // Safe defaults: synchronized output on (harmless if ignored),
95        // deccara/sixel off (harmful if unsupported).
96        Self {
97            color_level: ColorLevel::None,
98            image_protocol: None,
99            true_color: false,
100            hyperlinks: false,
101            kitty_protocol: false,
102            sixel: false,
103            synchronized_output: true,
104            deccara: false,
105            cell_size: None,
106            terminal_name: None,
107        }
108    }
109}
110
111impl TerminalCaps {
112    /// Detect terminal capabilities from environment variables.
113    ///
114    /// Reads `NO_COLOR`/`COLORTERM`/`TERM` for `color_level`, and
115    /// `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`/
116    /// `WEZTERM_PANE`/`ITERM_SESSION_ID`/`TMUX`/`OXI_NO_SYNC_OUTPUT` for
117    /// per-kind booleans.
118    #[must_use]
119    pub fn detect() -> Self {
120        detect::detect_with_env()
121    }
122
123    /// Downgrade all colors in the theme to match the terminal's color
124    /// level. Called once at bootstrap after `Theme::dark()` etc.
125    ///
126    /// Re-derives [`crate::theme::ThemeStyles`] after mutating colors so
127    /// downstream consumers never observe stale styles.
128    pub fn adapt_theme(&self, theme: &mut Theme) {
129        if self.color_level >= ColorLevel::TrueColor {
130            return; // no downgrade needed
131        }
132        adapt_color_scheme(&mut theme.colors, self.color_level);
133        theme.styles = crate::theme::ThemeStyles::from_colors(&theme.colors);
134    }
135
136    /// Check if images are supported.
137    #[must_use]
138    pub fn supports_images(&self) -> bool {
139        self.image_protocol.is_some()
140    }
141}
142
143/// Adapt a single [`Color`] for the terminal's [`ColorLevel`].
144///
145/// - `None` ⇒ everything becomes [`Color::Reset`] (no color).
146/// - `TrueColor` ⇒ unchanged.
147/// - `Ansi256` ⇒ `Rgb` mapped to nearest xterm-256 index.
148/// - `Basic` ⇒ further collapsed to ANSI 16 via heuristic.
149#[allow(clippy::match_same_arms)] // TrueColor catch-all + basic-16 passthrough share body but disjoint levels
150#[must_use]
151pub fn adapt_color(color: Color, level: ColorLevel) -> Color {
152    match (color, level) {
153        // None ⇒ strip everything to Reset.
154        (_, ColorLevel::None) => Color::Reset,
155        // Reset stays Reset at every level.
156        (Color::Reset, _) => Color::Reset,
157        // TrueColor ⇒ no downgrade needed (catch-all).
158        (c, ColorLevel::TrueColor) => c,
159        // Rgb ⇒ quantize to the nearest indexed palette entry.
160        (Color::Rgb(r, g, b), ColorLevel::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
161        (Color::Rgb(r, g, b), ColorLevel::Basic) => {
162            Color::Indexed(ansi256_to_basic(rgb_to_ansi256(r, g, b)))
163        }
164        // DarkGray has no Basic counterpart ⇒ round up to Gray at Basic/Ansi256.
165        (Color::DarkGray, ColorLevel::Basic | ColorLevel::Ansi256) => Color::Gray,
166        // Light* variants aren't part of ANSI 16 ⇒ collapse to non-light at Basic.
167        (
168            c @ (Color::LightRed
169            | Color::LightGreen
170            | Color::LightYellow
171            | Color::LightBlue
172            | Color::LightMagenta
173            | Color::LightCyan),
174            ColorLevel::Basic,
175        ) => darken_light(c),
176        // Indexed at Basic ⇒ map through the 16-color heuristic.
177        (Color::Indexed(idx), ColorLevel::Basic) => Color::Indexed(ansi256_to_basic(idx)),
178        // Remaining basic 16 colors + Indexed at Ansi256 pass through unchanged.
179        // (Light* at Ansi256 also survive — they're valid xterm-256 palette entries.)
180        (
181            c @ (Color::Black
182            | Color::Red
183            | Color::Green
184            | Color::Yellow
185            | Color::Blue
186            | Color::Magenta
187            | Color::Cyan
188            | Color::White
189            | Color::Gray
190            | Color::LightRed
191            | Color::LightGreen
192            | Color::LightYellow
193            | Color::LightBlue
194            | Color::LightMagenta
195            | Color::LightCyan
196            | Color::Indexed(_)),
197            ColorLevel::Basic | ColorLevel::Ansi256,
198        ) => c,
199    }
200}
201
202fn darken_light(c: Color) -> Color {
203    match c {
204        Color::LightRed => Color::Red,
205        Color::LightGreen => Color::Green,
206        Color::LightYellow => Color::Yellow,
207        Color::LightBlue => Color::Blue,
208        Color::LightMagenta => Color::Magenta,
209        Color::LightCyan => Color::Cyan,
210        _ => c,
211    }
212}
213
214fn adapt_color_scheme(scheme: &mut ColorScheme, level: ColorLevel) {
215    macro_rules! adapt_field {
216        ($f:ident) => {
217            scheme.$f = adapt_color(scheme.$f, level);
218        };
219    }
220    adapt_field!(background);
221    adapt_field!(foreground);
222    adapt_field!(primary);
223    adapt_field!(accent);
224    adapt_field!(muted);
225    adapt_field!(border);
226    adapt_field!(user);
227    adapt_field!(user_bg);
228    adapt_field!(response);
229    adapt_field!(response_bg);
230    adapt_field!(thinking);
231    adapt_field!(thinking_bg);
232    adapt_field!(tool);
233    adapt_field!(tool_bg);
234    adapt_field!(success);
235    adapt_field!(warning);
236    adapt_field!(error);
237    adapt_field!(info);
238    adapt_field!(diff_add);
239    adapt_field!(diff_remove);
240    adapt_field!(diff_hunk);
241    adapt_field!(surface_bg);
242    adapt_field!(panel_bg);
243    adapt_field!(code_bg);
244    adapt_field!(selection_bg);
245    adapt_field!(diff_add_bg);
246    adapt_field!(diff_remove_bg);
247    adapt_field!(diff_hunk_bg);
248}
249
250/// RGB → ANSI 256 (xterm 216-cube + 24 grayscale).
251fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
252    if r == g && g == b {
253        if r < 8 {
254            return 16;
255        }
256        if r > 248 {
257            return 231;
258        }
259        return u8::try_from(u16::from(r - 8) * 24 / 237 + 232).unwrap_or(232);
260    }
261    let ri = u16::from(r) * 5 / 255;
262    let gi = u16::from(g) * 5 / 255;
263    let bi = u16::from(b) * 5 / 255;
264    u8::try_from(16 + 36 * ri + 6 * gi + bi).unwrap_or(16)
265}
266
267/// ANSI 256 → basic 16 (heuristic).
268fn ansi256_to_basic(idx: u8) -> u8 {
269    match idx {
270        0..=15 => idx,            // already basic (8 normal + 8 bright)
271        16 => 0,                  // #000000 → black
272        17..=51 => 4,             // blue cube
273        52..=87 | 124..=159 => 1, // red cube / red repeat
274        88..=123 => 5,            // magenta cube
275        160..=195 => 3,           // yellow cube
276        196..=231 => {
277            if idx < 214 {
278                1
279            } else {
280                3
281            }
282        } // red-yellow
283        232..=255 => 7,           // grayscale → white (lossy)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn adapt_color_resets_at_none_level() {
293        assert_eq!(adapt_color(Color::Red, ColorLevel::None), Color::Reset);
294        assert_eq!(
295            adapt_color(Color::Rgb(1, 2, 3), ColorLevel::None),
296            Color::Reset
297        );
298        assert_eq!(
299            adapt_color(Color::Indexed(42), ColorLevel::None),
300            Color::Reset
301        );
302    }
303
304    #[test]
305    fn adapt_color_keeps_truecolor_unchanged() {
306        let c = Color::Rgb(123, 45, 67);
307        assert_eq!(adapt_color(c, ColorLevel::TrueColor), c);
308        assert_eq!(adapt_color(Color::Red, ColorLevel::TrueColor), Color::Red);
309    }
310
311    #[test]
312    fn adapt_color_rgb_to_ansi256_red() {
313        assert_eq!(
314            adapt_color(Color::Rgb(255, 0, 0), ColorLevel::Ansi256),
315            Color::Indexed(196)
316        );
317    }
318
319    #[test]
320    fn adapt_color_keeps_basic_colors() {
321        assert_eq!(adapt_color(Color::Red, ColorLevel::Basic), Color::Red);
322        assert_eq!(adapt_color(Color::Cyan, ColorLevel::Basic), Color::Cyan);
323        // DarkGray at Basic collapses to Gray (its bright counterpart).
324        assert_eq!(adapt_color(Color::DarkGray, ColorLevel::Basic), Color::Gray);
325    }
326
327    #[test]
328    fn adapt_color_collapses_light_to_basic_at_basic_level() {
329        // LightRed at Basic → Red.
330        assert_eq!(adapt_color(Color::LightRed, ColorLevel::Basic), Color::Red);
331        assert_eq!(
332            adapt_color(Color::LightCyan, ColorLevel::Basic),
333            Color::Cyan
334        );
335        // At Ansi256, light variants survive (still Indexed mappable).
336        assert_eq!(
337            adapt_color(Color::LightRed, ColorLevel::Ansi256),
338            Color::LightRed
339        );
340    }
341
342    #[test]
343    fn adapt_color_rgb_to_basic_via_index() {
344        // Pure red RGB → Indexed(196) → Basic red (idx 1).
345        assert_eq!(
346            adapt_color(Color::Rgb(255, 0, 0), ColorLevel::Basic),
347            Color::Indexed(1)
348        );
349    }
350
351    #[test]
352    fn no_color_env_returns_none_level() {
353        // Pure-fn test: detects NO_COLOR via the parameterized
354        // `detect::detect_color_level`. The crate forbids `unsafe env::set_var`,
355        // so the real-env round-trip is exercised in `detect.rs` tests.
356        let level = detect::detect_color_level(Some("1"), "truecolor", "xterm-256color", true);
357        assert_eq!(level, ColorLevel::None);
358    }
359
360    #[test]
361    fn color_level_has_helpers() {
362        assert!(!ColorLevel::None.has_color());
363        assert!(ColorLevel::Basic.has_color());
364        assert!(!ColorLevel::Basic.has_256());
365        assert!(ColorLevel::Ansi256.has_256());
366        assert!(!ColorLevel::Ansi256.has_truecolor());
367        assert!(ColorLevel::TrueColor.has_truecolor());
368    }
369
370    #[test]
371    fn adapt_theme_skips_downgrade_at_truecolor() {
372        let mut theme = Theme::dark();
373        let original_bg = theme.colors.background;
374        let caps = TerminalCaps {
375            color_level: ColorLevel::TrueColor,
376            ..TerminalCaps::default()
377        };
378        caps.adapt_theme(&mut theme);
379        assert_eq!(theme.colors.background, original_bg);
380    }
381
382    #[test]
383    fn adapt_theme_downgrades_rgb_to_ansi256() {
384        let mut theme = Theme::dark();
385        // Force one field to RGB so we can verify downgrade.
386        theme.colors.accent = Color::Rgb(255, 0, 0);
387        let caps = TerminalCaps {
388            color_level: ColorLevel::Ansi256,
389            ..TerminalCaps::default()
390        };
391        caps.adapt_theme(&mut theme);
392        assert_eq!(theme.colors.accent, Color::Indexed(196));
393        // Style must be re-derived from the new color.
394        assert_eq!(theme.styles.accent.fg, Some(Color::Indexed(196)));
395    }
396
397    #[test]
398    fn adapt_theme_at_none_resets_all_to_default_bg() {
399        let mut theme = Theme::dark();
400        let caps = TerminalCaps {
401            color_level: ColorLevel::None,
402            ..TerminalCaps::default()
403        };
404        caps.adapt_theme(&mut theme);
405        // Every color must be Reset.
406        assert_eq!(theme.colors.background, Color::Reset);
407        assert_eq!(theme.colors.foreground, Color::Reset);
408        assert_eq!(theme.colors.primary, Color::Reset);
409        assert_eq!(theme.colors.accent, Color::Reset);
410    }
411
412    #[test]
413    fn rgb_to_ansi256_pure_red_is_196() {
414        assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
415    }
416
417    #[test]
418    fn ansi256_to_basic_known_indices() {
419        // 196 (red) → 1 (basic red).
420        assert_eq!(ansi256_to_basic(196), 1);
421        // 21 (basic blue in cube) → 4 (basic blue).
422        assert_eq!(ansi256_to_basic(21), 4);
423        // 0 (black) → 0.
424        assert_eq!(ansi256_to_basic(0), 0);
425        // 15 (bright white) → 15.
426        assert_eq!(ansi256_to_basic(15), 15);
427        // 240 (dark gray) → 7 (white, lossy).
428        assert_eq!(ansi256_to_basic(240), 7);
429    }
430}