Skip to main content

cli/theme/
color.rs

1//! Pure color-classification logic: OSC 11 RGB parsing, luminance-based
2//! light/dark classification, and `COLORFGBG` parsing. No I/O, fully
3//! cross-platform, and unit-testable without a terminal.
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum Theme {
7    Light,
8    Dark,
9}
10
11impl Theme {
12    pub fn as_str(self) -> &'static str {
13        match self {
14            Theme::Light => "light",
15            Theme::Dark => "dark",
16        }
17    }
18}
19
20/// Parses `value` (`"light"` or `"dark"`) as a [`Theme`]. Any other value —
21/// including case variants — is rejected: `SHINE_TERMINAL_THEME` is a
22/// display-only signal (PRD §10), not something to interpret loosely.
23pub fn parse_theme_str(value: &str) -> Option<Theme> {
24    match value {
25        "light" => Some(Theme::Light),
26        "dark" => Some(Theme::Dark),
27        _ => None,
28    }
29}
30
31/// Parses an OSC 11 response body of the form `rgb:RRRR/GGGG/BBBB` (each
32/// component 1-4 hex digits, matching XParseColor's `rgb:` device format)
33/// into 8-bit RGB, scaling each component from its source bit depth to
34/// 0-255.
35#[cfg(unix)]
36pub fn parse_osc_rgb(body: &str) -> Option<(u8, u8, u8)> {
37    let rest = body.strip_prefix("rgb:")?;
38    let mut parts = rest.splitn(3, '/');
39    let r = parts.next()?;
40    let g = parts.next()?;
41    let b = parts.next()?;
42    if parts.next().is_some() {
43        return None;
44    }
45    Some((
46        scale_hex_component(r)?,
47        scale_hex_component(g)?,
48        scale_hex_component(b)?,
49    ))
50}
51
52#[cfg(unix)]
53fn scale_hex_component(hex: &str) -> Option<u8> {
54    if hex.is_empty() || hex.len() > 4 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
55        return None;
56    }
57    let value = u32::from_str_radix(hex, 16).ok()?;
58    let max = 16u32.pow(hex.len() as u32) - 1;
59    Some(((value * 255) / max) as u8)
60}
61
62/// Classifies an RGB triple as light/dark using the same weighted-luma
63/// threshold as the (now-superseded) shell implementation
64/// (`299R + 587G + 114B >= 128000` over 0-255-scaled components), so the
65/// visible light/dark boundary doesn't shift for existing users — only the
66/// read-timing bug (docs/kb/lessons.md, 2026-07-14) is fixed, not the
67/// classification itself.
68#[cfg(unix)]
69pub fn theme_from_rgb(r: u8, g: u8, b: u8) -> Theme {
70    let luma = 299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b);
71    if luma >= 128_000 {
72        Theme::Light
73    } else {
74        Theme::Dark
75    }
76}
77
78/// Parses `COLORFGBG` (`"fg;bg"`, or `"fg;dark_bg;bg"` on some terminals —
79/// only the last field is ever the background) into a [`Theme`] using the
80/// conventional xterm 16-color palette: indices 0-6 and 8 are dark, 7 and
81/// 9-15 are light.
82pub fn parse_colorfgbg(value: &str) -> Option<Theme> {
83    let bg = value.rsplit(';').next()?.trim();
84    let index: u8 = bg.parse().ok()?;
85    Some(if matches!(index, 7 | 9..=15) {
86        Theme::Light
87    } else {
88        Theme::Dark
89    })
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn parse_theme_str_accepts_only_exact_light_or_dark() {
98        assert_eq!(parse_theme_str("light"), Some(Theme::Light));
99        assert_eq!(parse_theme_str("dark"), Some(Theme::Dark));
100        assert_eq!(parse_theme_str("Light"), None);
101        assert_eq!(parse_theme_str("DARK"), None);
102        assert_eq!(parse_theme_str(""), None);
103        assert_eq!(parse_theme_str("light "), None);
104    }
105
106    #[cfg(unix)]
107    #[test]
108    fn parse_osc_rgb_scales_16_bit_components_to_8_bit() {
109        // Full-scale white: 0xffff/0xffff/0xffff -> 255/255/255.
110        assert_eq!(parse_osc_rgb("rgb:ffff/ffff/ffff"), Some((255, 255, 255)));
111        // Full-scale black.
112        assert_eq!(parse_osc_rgb("rgb:0000/0000/0000"), Some((0, 0, 0)));
113    }
114
115    #[cfg(unix)]
116    #[test]
117    fn parse_osc_rgb_scales_differing_component_widths() {
118        // A 2-digit component (0-255 native) must scale identically to a
119        // 4-digit component representing the same fraction.
120        assert_eq!(parse_osc_rgb("rgb:ff/ff/ff"), Some((255, 255, 255)));
121        assert_eq!(parse_osc_rgb("rgb:00/00/00"), Some((0, 0, 0)));
122    }
123
124    #[cfg(unix)]
125    #[test]
126    fn parse_osc_rgb_rejects_malformed_bodies() {
127        assert_eq!(parse_osc_rgb(""), None);
128        assert_eq!(parse_osc_rgb("rgb:ffff/ffff"), None); // missing component
129        assert_eq!(parse_osc_rgb("rgb:ffff/ffff/ffff/ffff"), None); // extra component
130        assert_eq!(parse_osc_rgb("rgb:gggg/ffff/ffff"), None); // non-hex
131        assert_eq!(parse_osc_rgb("rgb:fffff/ffff/ffff"), None); // too many digits
132        assert_eq!(parse_osc_rgb("rgb:/ffff/ffff"), None); // empty component
133        assert_eq!(parse_osc_rgb("not-rgb-at-all"), None);
134    }
135
136    #[cfg(unix)]
137    #[test]
138    fn theme_from_rgb_matches_shell_luma_threshold() {
139        // White: clearly light.
140        assert_eq!(theme_from_rgb(255, 255, 255), Theme::Light);
141        // Black: clearly dark.
142        assert_eq!(theme_from_rgb(0, 0, 0), Theme::Dark);
143        // Right at the shell's `>= 128000` threshold in 0-255-scaled terms:
144        // 299*128 + 587*128 + 114*128 = 1000*128 = 128000 -> light.
145        assert_eq!(theme_from_rgb(128, 128, 128), Theme::Light);
146        assert_eq!(theme_from_rgb(127, 127, 127), Theme::Dark);
147    }
148
149    #[test]
150    fn parse_colorfgbg_reads_only_the_last_field_as_background() {
151        assert_eq!(parse_colorfgbg("15;0"), Some(Theme::Dark));
152        assert_eq!(parse_colorfgbg("0;15"), Some(Theme::Light));
153        // Three-field variant some terminals emit: still only the last field.
154        assert_eq!(parse_colorfgbg("15;default;0"), Some(Theme::Dark));
155    }
156
157    #[test]
158    fn parse_colorfgbg_classifies_full_xterm_16_color_palette() {
159        for dark_index in [0, 1, 2, 3, 4, 5, 6, 8] {
160            assert_eq!(
161                parse_colorfgbg(&format!("15;{dark_index}")),
162                Some(Theme::Dark),
163                "index {dark_index} should be dark"
164            );
165        }
166        for light_index in [7, 9, 10, 11, 12, 13, 14, 15] {
167            assert_eq!(
168                parse_colorfgbg(&format!("0;{light_index}")),
169                Some(Theme::Light),
170                "index {light_index} should be light"
171            );
172        }
173    }
174
175    #[test]
176    fn parse_colorfgbg_rejects_malformed_values() {
177        assert_eq!(parse_colorfgbg(""), None);
178        assert_eq!(parse_colorfgbg("not-a-number"), None);
179        assert_eq!(parse_colorfgbg(";"), None);
180    }
181}