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}