Skip to main content

vtcode_commons/
color256_theme.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    reason = "The 256-color conversion clamps channel values to the palette range before narrowing."
4)]
5
6//! Theme-aware 256-color helpers.
7//!
8//! The terminal 256-color palette can be "non-harmonious" on light themes when
9//! palette semantics are intentionally flipped for compatibility. In that mode,
10//! cube/gray indices need to be reflected to keep visual intent stable.
11
12use std::sync::atomic::{AtomicU8, Ordering};
13
14const HARMONIOUS_FALSE: u8 = 0;
15const HARMONIOUS_TRUE: u8 = 1;
16const HARMONIOUS_UNSET: u8 = 2;
17
18static RUNTIME_HARMONIOUS_HINT: AtomicU8 = AtomicU8::new(HARMONIOUS_UNSET);
19
20fn resolve_harmony(is_light_theme: bool, env_override: Option<bool>, runtime_hint: Option<bool>) -> bool {
21    env_override.or(runtime_hint).unwrap_or(!is_light_theme)
22}
23
24/// Determine harmony mode for the current theme.
25///
26/// Precedence:
27/// 1. `VTCODE_256_HARMONIOUS` environment override (`1/0`, `true/false`, `yes/no`, `on/off`)
28/// 2. Runtime hint (typically from OSC probe cached at startup)
29/// 3. Default behavior: light themes are treated as non-harmonious for compatibility.
30fn is_harmonious_for_theme(is_light_theme: bool) -> bool {
31    resolve_harmony(is_light_theme, harmonious_override(), harmonious_runtime_hint())
32}
33
34fn harmonious_runtime_hint() -> Option<bool> {
35    match RUNTIME_HARMONIOUS_HINT.load(Ordering::Relaxed) {
36        HARMONIOUS_TRUE => Some(true),
37        HARMONIOUS_FALSE => Some(false),
38        _ => None,
39    }
40}
41
42/// Store a runtime harmony hint.
43///
44/// This is intended to be populated once at startup by terminal OSC probing.
45/// Set `None` to clear the runtime hint.
46pub fn set_harmonious_runtime_hint(value: Option<bool>) {
47    let encoded = match value {
48        Some(true) => HARMONIOUS_TRUE,
49        Some(false) => HARMONIOUS_FALSE,
50        None => HARMONIOUS_UNSET,
51    };
52    RUNTIME_HARMONIOUS_HINT.store(encoded, Ordering::Relaxed);
53}
54
55/// Reflected gray-ramp index (maps `0..=23` onto `232..=255`).
56fn gray_index(level: u8) -> u8 {
57    232 + (23 - level.min(23))
58}
59
60/// Reflected cube index (maps `r,g,b` in `0..=5` onto `16..=231`).
61#[allow(
62    clippy::cast_sign_loss,
63    reason = "Intentional compatibility, platform, or test-only suppression."
64)]
65fn cube_index(r: u8, g: u8, b: u8) -> u8 {
66    let r = r.min(5);
67    let g = g.min(5);
68    let b = b.min(5);
69
70    let max = r.max(g).max(b) as i16;
71    let min = r.min(g).min(b) as i16;
72    let offset = 5 - max - min;
73
74    let r = ((r as i16 + offset).clamp(0, 5)) as u8;
75    let g = ((g as i16 + offset).clamp(0, 5)) as u8;
76    let b = ((b as i16 + offset).clamp(0, 5)) as u8;
77
78    16 + 36 * r + 6 * g + b
79}
80
81/// Adjust an existing ANSI256 index for palette harmony.
82///
83/// - `16..=231` is treated as cube space.
84/// - `232..=255` is treated as grayscale ramp.
85/// - `0..=15` is left unchanged.
86fn adjust_index(index: u8, is_harmonious: bool) -> u8 {
87    if is_harmonious {
88        return index;
89    }
90
91    match index {
92        16..=231 => {
93            let adjusted = index - 16;
94            let r = adjusted / 36;
95            let g = (adjusted % 36) / 6;
96            let b = adjusted % 6;
97            cube_index(r, g, b)
98        }
99        232..=255 => gray_index(index - 232),
100        _ => index,
101    }
102}
103
104/// Adjust an ANSI256 index based on a light/dark theme hint.
105fn adjust_index_for_theme(index: u8, is_light_theme: bool) -> u8 {
106    adjust_index(index, is_harmonious_for_theme(is_light_theme))
107}
108
109/// Convert RGB to ANSI256 and apply theme-aware palette adjustment.
110pub fn rgb_to_ansi256_for_theme(r: u8, g: u8, b: u8, is_light_theme: bool) -> u8 {
111    let base_index = if r == g && g == b {
112        if r < 8 {
113            16
114        } else if r > 248 {
115            231
116        } else {
117            ((r as u16 - 8) / 10) as u8 + 232
118        }
119    } else {
120        let r_index = ((r as u16 * 5) / 255) as u8;
121        let g_index = ((g as u16 * 5) / 255) as u8;
122        let b_index = ((b as u16 * 5) / 255) as u8;
123        16 + 36 * r_index + 6 * g_index + b_index
124    };
125
126    adjust_index_for_theme(base_index, is_light_theme)
127}
128
129fn parse_bool(value: &str) -> Option<bool> {
130    match value.trim().to_ascii_lowercase().as_str() {
131        "1" | "true" | "yes" | "on" => Some(true),
132        "0" | "false" | "no" | "off" => Some(false),
133        _ => None,
134    }
135}
136
137fn harmonious_override() -> Option<bool> {
138    std::env::var("VTCODE_256_HARMONIOUS").ok().and_then(|value| parse_bool(&value))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn harmonious_indices_are_identity() {
147        assert_eq!(adjust_index(16, true), 16);
148        assert_eq!(adjust_index(231, true), 231);
149        assert_eq!(adjust_index(232, true), 232);
150        assert_eq!(adjust_index(255, true), 255);
151        assert_eq!(adjust_index(194, true), 194);
152    }
153
154    #[test]
155    fn non_harmonious_gray_and_cube_reflect() {
156        assert_eq!(gray_index(0), 255);
157        assert_eq!(gray_index(23), 232);
158        assert_eq!(cube_index(0, 0, 0), 231);
159        assert_eq!(cube_index(5, 5, 5), 16);
160    }
161
162    #[test]
163    fn non_harmonious_adjusts_existing_indices() {
164        assert_eq!(adjust_index(194, false), 22);
165        assert_eq!(adjust_index(224, false), 52);
166        assert_eq!(adjust_index(233, false), 254);
167        assert_eq!(adjust_index(14, false), 14);
168    }
169
170    #[test]
171    fn rgb_to_ansi256_applies_theme_adjustment() {
172        assert_eq!(rgb_to_ansi256_for_theme(0, 0, 0, false), 16);
173        assert_eq!(rgb_to_ansi256_for_theme(0, 0, 0, true), 231);
174        assert_eq!(rgb_to_ansi256_for_theme(255, 255, 255, false), 231);
175        assert_eq!(rgb_to_ansi256_for_theme(255, 255, 255, true), 16);
176    }
177
178    #[test]
179    fn resolve_harmony_precedence_is_env_then_runtime_then_default() {
180        assert!(resolve_harmony(true, Some(true), Some(false)));
181        assert!(!resolve_harmony(false, Some(false), Some(true)));
182        assert!(resolve_harmony(true, None, Some(true)));
183        assert!(!resolve_harmony(true, None, None));
184        assert!(resolve_harmony(false, None, None));
185    }
186}