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            &mut self.color_replace_preview,
136        ]
137        .into_iter()
138    }
139}
140
141/// Nearest xterm-256 palette index for an RGB color.
142///
143/// Considers the 6×6×6 color cube (16–231) and the grayscale ramp (232–255);
144/// the 16 base slots are skipped because their colors are user-configurable
145/// and unpredictable.
146fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
147    // Cube candidate: snap each channel to the nearest cube level.
148    let cube_idx = |c: u8| -> u8 {
149        // Levels: 0, 95, 135, 175, 215, 255.
150        if c < 48 {
151            0
152        } else if c < 115 {
153            1
154        } else {
155            ((c as u16 - 35) / 40).min(5) as u8
156        }
157    };
158    let level = |i: u8| -> u8 { if i == 0 { 0 } else { 55 + i * 40 } };
159    let (ci, cg, cb) = (cube_idx(r), cube_idx(g), cube_idx(b));
160    let cube = (16 + 36 * ci as u16 + 6 * cg as u16 + cb as u16) as u8;
161    let cube_rgb = (level(ci), level(cg), level(cb));
162
163    // Gray candidate: ramp 232–255 holds 8 + 10*i for i in 0..24.
164    let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
165    let gi = if gray_avg < 8 {
166        0
167    } else {
168        (((gray_avg - 8) + 5) / 10).min(23)
169    };
170    let gray = (232 + gi) as u8;
171    let gl = (8 + 10 * gi) as u8;
172    let gray_rgb = (gl, gl, gl);
173
174    let dist = |(cr, cg2, cb2): (u8, u8, u8)| -> u32 {
175        let dr = r as i32 - cr as i32;
176        let dg = g as i32 - cg2 as i32;
177        let db = b as i32 - cb2 as i32;
178        (dr * dr + dg * dg + db * db) as u32
179    };
180
181    if dist(gray_rgb) < dist(cube_rgb) {
182        gray
183    } else {
184        cube
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn detects_truecolor_from_colorterm() {
194        assert_eq!(
195            from_env(Some("truecolor"), Some("xterm-256color")),
196            ColorDepth::TrueColor
197        );
198        assert_eq!(from_env(Some("24bit"), None), ColorDepth::TrueColor);
199    }
200
201    #[test]
202    fn detects_truecolor_from_term_direct() {
203        assert_eq!(from_env(None, Some("xterm-direct")), ColorDepth::TrueColor);
204    }
205
206    #[test]
207    fn detects_256color_from_term() {
208        assert_eq!(from_env(None, Some("xterm-256color")), ColorDepth::Ansi256);
209        assert_eq!(
210            from_env(Some(""), Some("screen-256color")),
211            ColorDepth::Ansi256
212        );
213    }
214
215    #[test]
216    fn falls_back_to_ansi16() {
217        assert_eq!(from_env(None, Some("xterm")), ColorDepth::Ansi16);
218        assert_eq!(from_env(None, None), ColorDepth::Ansi16);
219        assert_eq!(from_env(Some("yes"), Some("vt100")), ColorDepth::Ansi16);
220    }
221
222    #[test]
223    fn nearest_256_known_values() {
224        assert_eq!(nearest_256(0, 0, 0), 16); // cube black
225        assert_eq!(nearest_256(255, 255, 255), 231); // cube white
226        assert_eq!(nearest_256(255, 0, 0), 196); // pure red
227        assert_eq!(nearest_256(0, 255, 0), 46); // pure green
228        assert_eq!(nearest_256(0, 0, 255), 21); // pure blue
229        // Mid gray lands on the grayscale ramp, not the cube.
230        let gray = nearest_256(128, 128, 128);
231        assert!((232..=255).contains(&gray), "got {}", gray);
232    }
233
234    #[test]
235    fn truecolor_adapt_is_identity() {
236        let theme = Theme::gruvbox_dark();
237        assert_eq!(theme.clone().adapt(ColorDepth::TrueColor), theme);
238    }
239
240    #[test]
241    fn ansi256_adapt_leaves_no_rgb() {
242        let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi256);
243        let mut theme = theme;
244        for color in theme.roles_mut() {
245            assert!(
246                !matches!(color, ThemeColor::Rgb(..)),
247                "RGB role survived 256-color adaptation: {}",
248                color
249            );
250        }
251    }
252
253    #[test]
254    fn ansi16_adapt_delegates_to_builtin_ansi_mapping() {
255        let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi16);
256        // Identity preserved, every role from the built-in ANSI theme — the
257        // single source of truth for the role→slot mapping.
258        let expected = Theme {
259            name: "Gruvbox Dark".to_string(),
260            ..Theme::ansi()
261        };
262        assert_eq!(theme, expected);
263    }
264
265    #[test]
266    fn ansi16_adapt_has_no_rgb_for_any_builtin() {
267        for theme in Theme::builtins() {
268            let name = theme.name.clone();
269            let mut adapted = theme.adapt(ColorDepth::Ansi16);
270            for color in adapted.roles_mut() {
271                assert!(
272                    !matches!(color, ThemeColor::Rgb(..)),
273                    "theme {:?}: RGB role survived 16-color adaptation",
274                    name
275                );
276            }
277        }
278    }
279}