Skip to main content

kimun_notes/settings/themes/
color_depth.rs

1//! Terminal color-depth detection and theme adaptation.
2//!
3//! Themes are authored in truecolor (RGB). Terminals that only support 256 or
4//! 16 colors get an adapted copy of the theme:
5//!
6//! - **256 colors** — every RGB role is quantized to the nearest slot of the
7//!   xterm-256 palette (6×6×6 color cube + grayscale ramp).
8//! - **16 colors** — RGB values cannot be represented faithfully, so the
9//!   theme falls back to the built-in ANSI theme's role→slot mapping (the
10//!   single source of truth for "which ANSI slot does each role get") and the
11//!   user's terminal palette supplies the actual colors.
12
13use super::{Theme, ThemeColor};
14
15/// Color capability of the terminal.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ColorDepth {
18    /// 24-bit RGB.
19    TrueColor,
20    /// xterm 256-color palette.
21    Ansi256,
22    /// The 16 standard ANSI colors.
23    Ansi16,
24}
25
26/// Detect the terminal's color depth from the environment.
27///
28/// The result is cached for the lifetime of the process — the terminal a TUI
29/// runs in cannot change mid-session.
30pub fn detect() -> ColorDepth {
31    static DEPTH: std::sync::OnceLock<ColorDepth> = std::sync::OnceLock::new();
32    *DEPTH.get_or_init(|| {
33        from_env(
34            std::env::var("COLORTERM").ok().as_deref(),
35            std::env::var("TERM").ok().as_deref(),
36        )
37    })
38}
39
40/// Pure detection logic, separated from the environment for testability.
41fn from_env(colorterm: Option<&str>, term: Option<&str>) -> ColorDepth {
42    if let Some(ct) = colorterm {
43        let ct = ct.to_ascii_lowercase();
44        if ct.contains("truecolor") || ct.contains("24bit") {
45            return ColorDepth::TrueColor;
46        }
47    }
48    if let Some(t) = term {
49        let t = t.to_ascii_lowercase();
50        // Some terminals advertise truecolor via TERM (e.g. xterm-direct).
51        if t.contains("direct") || t.contains("truecolor") {
52            return ColorDepth::TrueColor;
53        }
54        if t.contains("256color") {
55            return ColorDepth::Ansi256;
56        }
57    }
58    ColorDepth::Ansi16
59}
60
61impl Theme {
62    /// Adapt this theme to the terminal the process is running in.
63    ///
64    /// The one entry point display paths should use — `AppSettings::get_theme()`
65    /// and the settings-screen live preview both funnel through it.
66    pub fn adapt_to_terminal(self) -> Theme {
67        self.adapt(detect())
68    }
69
70    /// Return a copy of this theme adapted to the given color depth.
71    ///
72    /// Truecolor terminals get the theme unchanged.
73    pub fn adapt(self, depth: ColorDepth) -> Theme {
74        match depth {
75            ColorDepth::TrueColor => self,
76            ColorDepth::Ansi256 => self.into_quantized_256(),
77            ColorDepth::Ansi16 => self.into_ansi16(),
78        }
79    }
80
81    /// Quantize every RGB role to the nearest xterm-256 palette slot.
82    fn into_quantized_256(mut self) -> Theme {
83        for color in self.roles_mut() {
84            if let ThemeColor::Rgb(r, g, b) = *color {
85                *color = ThemeColor::Ansi(nearest_256(r, g, b));
86            }
87        }
88        self
89    }
90
91    /// Map every role to its canonical ANSI-16 slot.
92    ///
93    /// The theme's RGB values are discarded: on a 16-color terminal the user's
94    /// palette is the only color source, so role *semantics* (not hues) are
95    /// what must survive. The built-in ANSI theme owns the role→slot mapping —
96    /// a single source of truth — and only the theme's identity (its name) is
97    /// kept.
98    fn into_ansi16(self) -> Theme {
99        Theme {
100            name: self.name,
101            ..Theme::ansi()
102        }
103    }
104
105    /// Mutable iterator over every color role, for whole-theme transforms.
106    fn roles_mut(&mut self) -> impl Iterator<Item = &mut ThemeColor> {
107        [
108            &mut self.bg,
109            &mut self.bg_hard,
110            &mut self.bg_soft,
111            &mut self.bg_panel,
112            &mut self.selection_bg,
113            &mut self.fg,
114            &mut self.fg_bright,
115            &mut self.fg_secondary,
116            &mut self.gray,
117            &mut self.selection_fg,
118            &mut self.border_dim,
119            &mut self.focus_border,
120            &mut self.accent,
121            &mut self.cursor,
122            &mut self.red,
123            &mut self.green,
124            &mut self.yellow,
125            &mut self.blue,
126            &mut self.purple,
127            &mut self.aqua,
128            &mut self.orange,
129            &mut self.color_directory,
130            &mut self.color_journal_date,
131            &mut self.color_search_match,
132            &mut self.color_tag,
133            &mut self.blockquote_bar,
134            &mut self.code_bg,
135        ]
136        .into_iter()
137    }
138}
139
140/// Nearest xterm-256 palette index for an RGB color.
141///
142/// Considers the 6×6×6 color cube (16–231) and the grayscale ramp (232–255);
143/// the 16 base slots are skipped because their colors are user-configurable
144/// and unpredictable.
145fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
146    // Cube candidate: snap each channel to the nearest cube level.
147    let cube_idx = |c: u8| -> u8 {
148        // Levels: 0, 95, 135, 175, 215, 255.
149        if c < 48 {
150            0
151        } else if c < 115 {
152            1
153        } else {
154            ((c as u16 - 35) / 40).min(5) as u8
155        }
156    };
157    let level = |i: u8| -> u8 { if i == 0 { 0 } else { 55 + i * 40 } };
158    let (ci, cg, cb) = (cube_idx(r), cube_idx(g), cube_idx(b));
159    let cube = (16 + 36 * ci as u16 + 6 * cg as u16 + cb as u16) as u8;
160    let cube_rgb = (level(ci), level(cg), level(cb));
161
162    // Gray candidate: ramp 232–255 holds 8 + 10*i for i in 0..24.
163    let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
164    let gi = if gray_avg < 8 {
165        0
166    } else {
167        (((gray_avg - 8) + 5) / 10).min(23)
168    };
169    let gray = (232 + gi) as u8;
170    let gl = (8 + 10 * gi) as u8;
171    let gray_rgb = (gl, gl, gl);
172
173    let dist = |(cr, cg2, cb2): (u8, u8, u8)| -> u32 {
174        let dr = r as i32 - cr as i32;
175        let dg = g as i32 - cg2 as i32;
176        let db = b as i32 - cb2 as i32;
177        (dr * dr + dg * dg + db * db) as u32
178    };
179
180    if dist(gray_rgb) < dist(cube_rgb) {
181        gray
182    } else {
183        cube
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn detects_truecolor_from_colorterm() {
193        assert_eq!(
194            from_env(Some("truecolor"), Some("xterm-256color")),
195            ColorDepth::TrueColor
196        );
197        assert_eq!(from_env(Some("24bit"), None), ColorDepth::TrueColor);
198    }
199
200    #[test]
201    fn detects_truecolor_from_term_direct() {
202        assert_eq!(from_env(None, Some("xterm-direct")), ColorDepth::TrueColor);
203    }
204
205    #[test]
206    fn detects_256color_from_term() {
207        assert_eq!(from_env(None, Some("xterm-256color")), ColorDepth::Ansi256);
208        assert_eq!(
209            from_env(Some(""), Some("screen-256color")),
210            ColorDepth::Ansi256
211        );
212    }
213
214    #[test]
215    fn falls_back_to_ansi16() {
216        assert_eq!(from_env(None, Some("xterm")), ColorDepth::Ansi16);
217        assert_eq!(from_env(None, None), ColorDepth::Ansi16);
218        assert_eq!(from_env(Some("yes"), Some("vt100")), ColorDepth::Ansi16);
219    }
220
221    #[test]
222    fn nearest_256_known_values() {
223        assert_eq!(nearest_256(0, 0, 0), 16); // cube black
224        assert_eq!(nearest_256(255, 255, 255), 231); // cube white
225        assert_eq!(nearest_256(255, 0, 0), 196); // pure red
226        assert_eq!(nearest_256(0, 255, 0), 46); // pure green
227        assert_eq!(nearest_256(0, 0, 255), 21); // pure blue
228        // Mid gray lands on the grayscale ramp, not the cube.
229        let gray = nearest_256(128, 128, 128);
230        assert!((232..=255).contains(&gray), "got {}", gray);
231    }
232
233    #[test]
234    fn truecolor_adapt_is_identity() {
235        let theme = Theme::gruvbox_dark();
236        assert_eq!(theme.clone().adapt(ColorDepth::TrueColor), theme);
237    }
238
239    #[test]
240    fn ansi256_adapt_leaves_no_rgb() {
241        let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi256);
242        let mut theme = theme;
243        for color in theme.roles_mut() {
244            assert!(
245                !matches!(color, ThemeColor::Rgb(..)),
246                "RGB role survived 256-color adaptation: {}",
247                color
248            );
249        }
250    }
251
252    #[test]
253    fn ansi16_adapt_delegates_to_builtin_ansi_mapping() {
254        let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi16);
255        // Identity preserved, every role from the built-in ANSI theme — the
256        // single source of truth for the role→slot mapping.
257        let expected = Theme {
258            name: "Gruvbox Dark".to_string(),
259            ..Theme::ansi()
260        };
261        assert_eq!(theme, expected);
262    }
263
264    #[test]
265    fn ansi16_adapt_has_no_rgb_for_any_builtin() {
266        for theme in Theme::builtins() {
267            let name = theme.name.clone();
268            let mut adapted = theme.adapt(ColorDepth::Ansi16);
269            for color in adapted.roles_mut() {
270                assert!(
271                    !matches!(color, ThemeColor::Rgb(..)),
272                    "theme {:?}: RGB role survived 16-color adaptation",
273                    name
274                );
275            }
276        }
277    }
278}