Skip to main content

zeph_tui/theme/
color_mode.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Terminal colour capability detection and colour-space downgrade pipeline.
5//!
6//! [`EffectiveColorMode`] is the resolved, non-`Auto` result of [`resolve_color_mode`].
7//! [`map_color`] and [`apply_mode`] are the single seams through which all palette colours
8//! pass during [`super::Theme`] derivation — never at render time.
9
10use ratatui::style::{Color, Style};
11use zeph_config::ColorMode;
12
13/// Resolved terminal colour capability — the result of [`resolve_color_mode`].
14///
15/// Unlike [`ColorMode`], this enum has no `Auto` variant. It is only produced
16/// after detection has run, so `from_palette_with_mode` cannot accidentally receive
17/// an unresolved value.
18///
19/// # Examples
20///
21/// ```rust
22/// use zeph_tui::theme::color_mode::{EffectiveColorMode, resolve_color_mode};
23/// use zeph_config::ColorMode;
24///
25/// let mode = resolve_color_mode(ColorMode::Truecolor);
26/// assert_eq!(mode, EffectiveColorMode::Truecolor);
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EffectiveColorMode {
30    /// 24-bit RGB — no downgrade performed.
31    Truecolor,
32    /// RGB colours are mapped to the nearest xterm-256 index.
33    Ansi256,
34    /// RGB colours are mapped to the nearest ANSI-16 named colour.
35    Ansi16,
36    /// All colours stripped; text modifiers (BOLD, DIM, UNDERLINED) are preserved.
37    Never,
38}
39
40/// Resolve a [`ColorMode`] to an [`EffectiveColorMode`], running terminal detection when `Auto`.
41///
42/// Detection order (per <https://no-color.org> and common terminal conventions):
43/// 1. `NO_COLOR` present in environment (any value, including empty) → `Never`.
44/// 2. `COLORTERM` ∈ `{truecolor, 24bit}` → `Truecolor`.
45/// 3. `TERM` contains `256color` → `Ansi256`.
46/// 4. `TERM` matches known 16-colour terminals → `Ansi16`.
47/// 5. `TERM=dumb` or `TERM` unset → `Never`.
48/// 6. Fallback (ambiguous) → `Ansi256` (safe modern default).
49///
50/// # Examples
51///
52/// ```rust
53/// use zeph_tui::theme::color_mode::{EffectiveColorMode, resolve_color_mode};
54/// use zeph_config::ColorMode;
55///
56/// // Non-Auto modes pass through unchanged.
57/// assert_eq!(resolve_color_mode(ColorMode::Ansi16), EffectiveColorMode::Ansi16);
58/// assert_eq!(resolve_color_mode(ColorMode::Never), EffectiveColorMode::Never);
59/// ```
60#[must_use]
61pub fn resolve_color_mode(mode: ColorMode) -> EffectiveColorMode {
62    match mode {
63        ColorMode::Truecolor => EffectiveColorMode::Truecolor,
64        ColorMode::Ansi256 => EffectiveColorMode::Ansi256,
65        ColorMode::Ansi16 => EffectiveColorMode::Ansi16,
66        ColorMode::Never => EffectiveColorMode::Never,
67        _ => detect(),
68    }
69}
70
71/// Detect whether the terminal is capable of rendering Unicode glyphs.
72///
73/// This is independent of colour support — a terminal with `NO_COLOR` set may still
74/// render Unicode box-drawing and symbol characters perfectly. Conversely, `TERM=dumb`
75/// typically implies a plain-text pipe environment where Unicode glyphs are not safe.
76///
77/// Detection order:
78/// 1. `TERM=dumb` → ASCII only.
79/// 2. `LANG` or `LC_ALL` containing `UTF-8` or `UTF8` → Unicode capable.
80/// 3. Default → Unicode capable (opt-in to ASCII, not opt-out).
81///
82/// # Examples
83///
84/// ```rust
85/// use zeph_tui::theme::color_mode::detect_unicode_capable;
86///
87/// // In a normal UTF-8 terminal environment, Unicode is supported.
88/// // (Result depends on environment; the function does not panic.)
89/// let _ = detect_unicode_capable();
90/// ```
91#[must_use]
92pub fn detect_unicode_capable() -> bool {
93    // TERM=dumb → plain text pipe, ASCII only.
94    if std::env::var("TERM").as_deref() == Ok("dumb") {
95        return false;
96    }
97    // Any LC_ALL / LANG mentioning UTF-8 or UTF8 → Unicode OK.
98    for var in ["LC_ALL", "LANG"] {
99        if let Ok(val) = std::env::var(var) {
100            let upper = val.to_uppercase();
101            if upper.contains("UTF-8") || upper.contains("UTF8") {
102                return true;
103            }
104        }
105    }
106    // Default: assume Unicode capable. ASCII mode is opt-in via TERM=dumb.
107    true
108}
109
110/// Detect the terminal's colour capability from the environment.
111///
112/// Per <https://no-color.org>: `NO_COLOR` disables colour when *present*, regardless of value.
113fn detect() -> EffectiveColorMode {
114    // M2: `NO_COLOR` — presence alone (even empty string) means disable colour.
115    if std::env::var_os("NO_COLOR").is_some() {
116        return EffectiveColorMode::Never;
117    }
118
119    if let Ok(colorterm) = std::env::var("COLORTERM")
120        && (colorterm == "truecolor" || colorterm == "24bit")
121    {
122        return EffectiveColorMode::Truecolor;
123    }
124
125    if let Ok(term) = std::env::var("TERM") {
126        if term == "dumb" {
127            return EffectiveColorMode::Never;
128        }
129        if term.contains("256color") {
130            return EffectiveColorMode::Ansi256;
131        }
132        // Known 16-colour terminal families.
133        let base = term.split('-').next().unwrap_or("");
134        if matches!(
135            base,
136            "xterm" | "screen" | "vt100" | "linux" | "rxvt" | "konsole"
137        ) {
138            return EffectiveColorMode::Ansi16;
139        }
140    } else {
141        // TERM unset — cannot determine capability.
142        return EffectiveColorMode::Never;
143    }
144
145    // Ambiguous — default to Ansi256 (safe, supported by most terminals since ~2017).
146    EffectiveColorMode::Ansi256
147}
148
149/// Map a single [`Color`] through the downgrade pipeline for the given mode.
150///
151/// `Rgb` values are converted to the nearest indexed colour for `Ansi256`/`Ansi16`,
152/// or stripped to `Color::Reset` for `Never`. Non-Rgb colours pass through unchanged.
153///
154/// # Examples
155///
156/// ```rust
157/// use ratatui::style::Color;
158/// use zeph_tui::theme::color_mode::{EffectiveColorMode, map_color};
159///
160/// let c = map_color(Color::Rgb(0, 0, 0), EffectiveColorMode::Truecolor);
161/// assert_eq!(c, Color::Rgb(0, 0, 0));
162///
163/// let stripped = map_color(Color::Rgb(255, 0, 0), EffectiveColorMode::Never);
164/// assert_eq!(stripped, Color::Reset);
165/// ```
166#[must_use]
167pub fn map_color(color: Color, mode: EffectiveColorMode) -> Color {
168    match mode {
169        EffectiveColorMode::Truecolor => color,
170        EffectiveColorMode::Never => Color::Reset,
171        EffectiveColorMode::Ansi256 => {
172            if let Color::Rgb(r, g, b) = color {
173                Color::Indexed(rgb_to_ansi256(r, g, b))
174            } else {
175                color
176            }
177        }
178        EffectiveColorMode::Ansi16 => {
179            if let Color::Rgb(r, g, b) = color {
180                Color::Indexed(rgb_to_ansi16(r, g, b))
181            } else {
182                color
183            }
184        }
185    }
186}
187
188/// Apply the colour mode to a [`Style`], downgrading all `Rgb` colours in fg/bg.
189///
190/// For [`EffectiveColorMode::Never`]: removes all fg/bg colours but preserves modifiers.
191///
192/// # Examples
193///
194/// ```rust
195/// use ratatui::style::{Color, Modifier, Style};
196/// use zeph_tui::theme::color_mode::{EffectiveColorMode, apply_mode};
197///
198/// let bold_red = Style::default().fg(Color::Rgb(255, 0, 0)).add_modifier(Modifier::BOLD);
199/// let stripped = apply_mode(bold_red, EffectiveColorMode::Never);
200/// assert_eq!(stripped.fg, None);
201/// assert_eq!(stripped.bg, None);
202/// assert!(stripped.add_modifier.contains(Modifier::BOLD));
203/// ```
204#[must_use]
205pub fn apply_mode(style: Style, mode: EffectiveColorMode) -> Style {
206    match mode {
207        EffectiveColorMode::Truecolor => style,
208        EffectiveColorMode::Never => Style {
209            fg: None,
210            bg: None,
211            underline_color: None,
212            add_modifier: style.add_modifier,
213            sub_modifier: style.sub_modifier,
214        },
215        EffectiveColorMode::Ansi256 | EffectiveColorMode::Ansi16 => {
216            let mut s = style;
217            if let Some(fg) = s.fg {
218                s.fg = Some(map_color(fg, mode));
219            }
220            if let Some(bg) = s.bg {
221                s.bg = Some(map_color(bg, mode));
222            }
223            if let Some(ul) = s.underline_color {
224                s.underline_color = Some(map_color(ul, mode));
225            }
226            s
227        }
228    }
229}
230
231// ── ANSI-256 mapping (M4: compute both cube and gray-ramp, pick nearest) ─────────────────────────
232
233/// Map an RGB triplet to the nearest xterm-256 palette index.
234///
235/// Per the critic's M4 requirement: always compute both the 6×6×6 cube candidate
236/// and the 24-step gray-ramp candidate and return whichever has smaller Euclidean distance.
237fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
238    let (cube_idx, cube_dist) = nearest_cube(r, g, b);
239    let (gray_idx, gray_dist) = nearest_gray_ramp(r, g, b);
240    if gray_dist <= cube_dist {
241        gray_idx
242    } else {
243        cube_idx
244    }
245}
246
247/// Quantise one 8-bit channel to the nearest of the 6 cube levels {0,95,135,175,215,255}.
248fn quantize_cube_level(v: u8) -> (u8, u8) {
249    // Cube levels and their 8-bit values.
250    const LEVELS: [(u8, u8); 6] = [(0, 0), (1, 95), (2, 135), (3, 175), (4, 215), (5, 255)];
251    const LEVEL_VALS: [u8; 6] = [0, 95, 135, 175, 215, 255];
252    let vi = i16::from(v);
253    let mut best_idx = 0u8;
254    let mut best_dist = i32::MAX;
255    for (idx, level) in LEVELS {
256        let d = i32::from((vi - i16::from(level)).abs());
257        if d < best_dist {
258            best_dist = d;
259            best_idx = idx;
260        }
261    }
262    (best_idx, LEVEL_VALS[best_idx as usize])
263}
264
265fn nearest_cube(r: u8, g: u8, b: u8) -> (u8, u32) {
266    let (ri, rv) = quantize_cube_level(r);
267    let (gi, gv) = quantize_cube_level(g);
268    let (bi, bv) = quantize_cube_level(b);
269    let idx = 16 + 36 * ri + 6 * gi + bi;
270    let dist = dist_sq(r, g, b, rv, gv, bv);
271    (idx, dist)
272}
273
274fn nearest_gray_ramp(r: u8, g: u8, b: u8) -> (u8, u32) {
275    // Gray ramp: indices 232–255, values 8,18,28,…,238 (step 10, 24 entries).
276    let luma = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000;
277    // Ramp values: 8 + 10 * n for n in 0..24.
278    let n = if luma < 8 {
279        0u8
280    } else if luma >= 238 {
281        23u8
282    } else {
283        u8::try_from((luma - 8 + 5) / 10).unwrap_or(23)
284    };
285    let n = n.min(23);
286    let gray_val = 8 + 10 * n;
287    let idx = 232 + n;
288    let dist = dist_sq(r, g, b, gray_val, gray_val, gray_val);
289    (idx, dist)
290}
291
292fn dist_sq(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
293    let dr = u32::from(r1.abs_diff(r2)).pow(2);
294    let dg = u32::from(g1.abs_diff(g2)).pow(2);
295    let db = u32::from(b1.abs_diff(b2)).pow(2);
296    dr + dg + db
297}
298
299// ── ANSI-16 mapping ───────────────────────────────────────────────────────────────────────────────
300
301/// Standard xterm ANSI-16 palette values (indices 0–15).
302const ANSI16_PALETTE: [(u8, u8, u8); 16] = [
303    (0, 0, 0),       // 0  Black
304    (128, 0, 0),     // 1  Red
305    (0, 128, 0),     // 2  Green
306    (128, 128, 0),   // 3  Yellow
307    (0, 0, 128),     // 4  Blue
308    (128, 0, 128),   // 5  Magenta
309    (0, 128, 128),   // 6  Cyan
310    (192, 192, 192), // 7  White
311    (128, 128, 128), // 8  BrightBlack (Dark Gray)
312    (255, 0, 0),     // 9  BrightRed
313    (0, 255, 0),     // 10 BrightGreen
314    (255, 255, 0),   // 11 BrightYellow
315    (0, 0, 255),     // 12 BrightBlue
316    (255, 0, 255),   // 13 BrightMagenta
317    (0, 255, 255),   // 14 BrightCyan
318    (255, 255, 255), // 15 BrightWhite
319];
320
321fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> u8 {
322    let mut best_idx = 0u8;
323    let mut best_dist = u32::MAX;
324    for (i, &(pr, pg, pb)) in ANSI16_PALETTE.iter().enumerate() {
325        let d = dist_sq(r, g, b, pr, pg, pb);
326        if d < best_dist {
327            best_dist = d;
328            #[allow(clippy::cast_possible_truncation)]
329            {
330                best_idx = i as u8;
331            } // 16 entries — always fits u8
332        }
333    }
334    best_idx
335}
336
337#[cfg(test)]
338mod tests {
339    use ratatui::style::Modifier;
340
341    use super::*;
342
343    #[test]
344    fn no_color_strips_all_colors() {
345        // NO_COLOR semantics: fg/bg removed, modifiers kept.
346        let style = Style::default()
347            .fg(Color::Rgb(255, 0, 0))
348            .bg(Color::Rgb(0, 0, 0))
349            .add_modifier(Modifier::BOLD);
350        let out = apply_mode(style, EffectiveColorMode::Never);
351        assert_eq!(out.fg, None);
352        assert_eq!(out.bg, None);
353        assert!(out.add_modifier.contains(Modifier::BOLD));
354    }
355
356    #[test]
357    fn truecolor_identity() {
358        let style = Style::default().fg(Color::Rgb(31, 185, 168));
359        assert_eq!(apply_mode(style, EffectiveColorMode::Truecolor), style);
360    }
361
362    #[test]
363    fn ansi256_black_maps_to_index_16() {
364        // Pure black (0,0,0) — cube index 16 (0+0+0 = 16), gray ramp index 232 (value 8).
365        // Distance to cube: 0; distance to gray ramp: 8^2*3 = 192. Cube wins.
366        let idx = rgb_to_ansi256(0, 0, 0);
367        assert_eq!(idx, 16, "black should map to cube index 16");
368    }
369
370    #[test]
371    fn ansi256_near_gray_picks_ramp() {
372        // (128, 128, 128) — equidistant among cube and ramp.
373        // Gray ramp: luma≈128, n=(128-8+5)/10=12, val=128, dist=0.
374        // Cube: quantize(128) → level 2 (135), dist=(128-135)^2 * 3 = 147.
375        let idx = rgb_to_ansi256(128, 128, 128);
376        assert!(
377            idx >= 232,
378            "near-gray (128,128,128) should prefer gray ramp, got {idx}"
379        );
380    }
381
382    #[test]
383    fn ansi256_color_downgrade() {
384        // #1FB9A8 (31, 185, 168) should produce a valid 0–255 index.
385        let idx = rgb_to_ansi256(31, 185, 168);
386        // idx is u8 (0–255 by type); just verify it doesn't panic.
387        let _ = idx;
388    }
389
390    #[test]
391    fn ansi16_pure_red() {
392        // (255, 0, 0) should map to index 9 (BrightRed) or 1 (Red).
393        let idx = rgb_to_ansi16(255, 0, 0);
394        assert!(
395            idx == 1 || idx == 9,
396            "pure red should map to red or bright red, got {idx}"
397        );
398    }
399
400    #[test]
401    fn map_color_never_resets() {
402        assert_eq!(
403            map_color(Color::Rgb(255, 128, 0), EffectiveColorMode::Never),
404            Color::Reset
405        );
406    }
407
408    #[test]
409    fn resolve_color_mode_passthrough() {
410        assert_eq!(
411            resolve_color_mode(ColorMode::Truecolor),
412            EffectiveColorMode::Truecolor
413        );
414        assert_eq!(
415            resolve_color_mode(ColorMode::Ansi256),
416            EffectiveColorMode::Ansi256
417        );
418        assert_eq!(
419            resolve_color_mode(ColorMode::Ansi16),
420            EffectiveColorMode::Ansi16
421        );
422        assert_eq!(
423            resolve_color_mode(ColorMode::Never),
424            EffectiveColorMode::Never
425        );
426    }
427
428    #[test]
429    #[serial_test::serial]
430    #[allow(unsafe_code)]
431    fn auto_with_no_color_env_resolves_to_never() {
432        // Temporarily set NO_COLOR. Per no-color.org, presence alone (even empty) disables colour.
433        // serial guards against parallel tests mutating the same env var.
434        // SAFETY: single-threaded via #[serial]; no other test reads this env var concurrently.
435        unsafe { std::env::set_var("NO_COLOR", "1") };
436        let result = resolve_color_mode(ColorMode::Auto);
437        unsafe { std::env::remove_var("NO_COLOR") };
438        assert_eq!(result, EffectiveColorMode::Never);
439    }
440
441    /// Clear the three env vars `detect_unicode_capable` reads, so each test starts from a
442    /// known-empty baseline instead of depending on whatever the host shell happens to export.
443    ///
444    /// SAFETY: caller runs under `#[serial_test::serial]`, so no other test observes env state
445    /// concurrently.
446    #[allow(unsafe_code)]
447    unsafe fn clear_unicode_env() {
448        unsafe {
449            std::env::remove_var("TERM");
450            std::env::remove_var("LANG");
451            std::env::remove_var("LC_ALL");
452        }
453    }
454
455    #[test]
456    #[serial_test::serial]
457    #[allow(unsafe_code)]
458    fn unicode_capable_term_dumb_returns_false_even_with_utf8_lang() {
459        // TERM=dumb short-circuits before LANG/LC_ALL are consulted (detection order #1).
460        // SAFETY: single-threaded via #[serial].
461        unsafe {
462            clear_unicode_env();
463            std::env::set_var("TERM", "dumb");
464            std::env::set_var("LANG", "en_US.UTF-8");
465        }
466        let result = detect_unicode_capable();
467        unsafe { clear_unicode_env() };
468        assert!(!result, "TERM=dumb must return false regardless of LANG");
469    }
470
471    #[test]
472    #[serial_test::serial]
473    #[allow(unsafe_code)]
474    fn unicode_capable_lang_utf8_returns_true() {
475        // SAFETY: single-threaded via #[serial].
476        unsafe {
477            clear_unicode_env();
478            std::env::set_var("TERM", "xterm-256color");
479            std::env::set_var("LANG", "en_US.UTF-8");
480        }
481        let result = detect_unicode_capable();
482        unsafe { clear_unicode_env() };
483        assert!(result, "LANG containing UTF-8 must return true");
484    }
485
486    #[test]
487    #[serial_test::serial]
488    #[allow(unsafe_code)]
489    fn unicode_capable_lc_all_utf8_returns_true() {
490        // Exercises the LC_ALL branch specifically (checked before LANG in the detection loop).
491        // SAFETY: single-threaded via #[serial].
492        unsafe {
493            clear_unicode_env();
494            std::env::set_var("TERM", "xterm-256color");
495            std::env::set_var("LC_ALL", "C.UTF-8");
496        }
497        let result = detect_unicode_capable();
498        unsafe { clear_unicode_env() };
499        assert!(result, "LC_ALL containing UTF-8 must return true");
500    }
501
502    #[test]
503    #[serial_test::serial]
504    #[allow(unsafe_code)]
505    fn unicode_capable_lowercase_utf8_is_still_detected() {
506        // Detection uppercases before matching, so lowercase "utf-8" must also count.
507        // SAFETY: single-threaded via #[serial].
508        unsafe {
509            clear_unicode_env();
510            std::env::set_var("TERM", "xterm-256color");
511            std::env::set_var("LANG", "en_US.utf-8");
512        }
513        let result = detect_unicode_capable();
514        unsafe { clear_unicode_env() };
515        assert!(result, "lowercase utf-8 in LANG must still be detected");
516    }
517
518    #[test]
519    #[serial_test::serial]
520    #[allow(unsafe_code)]
521    fn unicode_capable_unset_env_defaults_to_true() {
522        // No TERM/LANG/LC_ALL at all — default is Unicode-capable (opt-in to ASCII, not opt-out).
523        // SAFETY: single-threaded via #[serial].
524        unsafe { clear_unicode_env() };
525        let result = detect_unicode_capable();
526        unsafe { clear_unicode_env() };
527        assert!(result, "unset environment must default to Unicode-capable");
528    }
529
530    #[test]
531    #[serial_test::serial]
532    #[allow(unsafe_code)]
533    fn unicode_capable_non_utf8_lang_without_dumb_defaults_to_true() {
534        // Ambiguous case: TERM set but not "dumb", LANG/LC_ALL present but not UTF-8 — falls
535        // through to the same opt-in-to-ASCII default as the fully-unset case.
536        // SAFETY: single-threaded via #[serial].
537        unsafe {
538            clear_unicode_env();
539            std::env::set_var("TERM", "xterm");
540            std::env::set_var("LANG", "C");
541        }
542        let result = detect_unicode_capable();
543        unsafe { clear_unicode_env() };
544        assert!(
545            result,
546            "non-UTF-8 LANG without TERM=dumb must default to true"
547        );
548    }
549}