Skip to main content

photon_ui/theme/
ansi.rs

1//! ANSI escape sequence generation for RGB colors.
2//!
3//! Supports three rendering modes:
4//! - **TrueColor** (24-bit RGB) — default, best quality
5//! - **Color256** — xterm 256-color cube fallback
6//! - **Basic16** — coarse 16-color fallback
7
8use std::env;
9
10use super::Color;
11
12/// Terminal color support level.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
14pub enum ColorMode {
15    /// 24-bit RGB (`\x1b[38;2;R;G;Bm`).
16    #[default]
17    TrueColor,
18    /// xterm 256-color palette (`\x1b[38;5;Nm`).
19    Color256,
20    /// ANSI 16-color (`\x1b[30m`–`\x1b[37m`, `\x1b[90m`–`\x1b[97m`).
21    Basic16,
22}
23
24impl ColorMode {
25    /// Detect the best available color mode from environment variables.
26    ///
27    /// Checks `PHOTON_COLOR_MODE` first, then `COLORTERM`, then `TERM`.
28    pub fn detect() -> Self {
29        if let Ok(mode) = env::var("PHOTON_COLOR_MODE") {
30            match mode.as_str() {
31                | "truecolor" | "24bit" | "rgb" => return ColorMode::TrueColor,
32                | "256" | "256color" => return ColorMode::Color256,
33                | "16" | "basic" => return ColorMode::Basic16,
34                | _ => {},
35            }
36        }
37        if let Ok(ct) = env::var("COLORTERM") &&
38            (ct == "truecolor" || ct == "24bit")
39        {
40            return ColorMode::TrueColor;
41        }
42        if let Ok(term) = env::var("TERM") &&
43            term.contains("256color")
44        {
45            return ColorMode::Color256;
46        }
47        ColorMode::TrueColor
48    }
49}
50
51/// Generate a foreground ANSI escape sequence for the given color.
52pub fn fg(color: Color, mode: ColorMode) -> String {
53    match mode {
54        | ColorMode::TrueColor => format!("\x1b[38;2;{};{};{}m", color.0, color.1, color.2),
55        | ColorMode::Color256 => format!("\x1b[38;5;{}m", rgb_to_256(color)),
56        | ColorMode::Basic16 => format!("\x1b[{}m", rgb_to_16_fg(color)),
57    }
58}
59
60/// Generate a background ANSI escape sequence for the given color.
61pub fn bg(color: Color, mode: ColorMode) -> String {
62    match mode {
63        | ColorMode::TrueColor => format!("\x1b[48;2;{};{};{}m", color.0, color.1, color.2),
64        | ColorMode::Color256 => format!("\x1b[48;5;{}m", rgb_to_256(color)),
65        | ColorMode::Basic16 => format!("\x1b[{}m", rgb_to_16_bg(color)),
66    }
67}
68
69/// Reset all ANSI attributes.
70pub const RESET: &str = "\x1b[0m";
71
72// ── 256-color conversion ──────────────────────────────────────────
73
74fn rgb_to_256(color: Color) -> u8 {
75    let Color(r, g, b) = color;
76
77    // Check if grayscale
78    if r == g && g == b {
79        if r < 8 {
80            return 16;
81        }
82        if r > 248 {
83            return 231;
84        }
85        return 232 + ((r - 8) / 10);
86    }
87
88    // 6x6x6 color cube
89    let r = closest_cube_level(r);
90    let g = closest_cube_level(g);
91    let b = closest_cube_level(b);
92    16 + 36 * r + 6 * g + b
93}
94
95fn closest_cube_level(v: u8) -> u8 {
96    // Levels: 0, 95, 135, 175, 215, 255
97    if v < 48 {
98        0
99    } else if v < 115 {
100        1
101    } else if v < 155 {
102        2
103    } else if v < 195 {
104        3
105    } else if v < 235 {
106        4
107    } else {
108        5
109    }
110}
111
112// ── 16-color conversion ───────────────────────────────────────────
113
114fn rgb_to_16_fg(color: Color) -> u8 {
115    30 + rgb_to_16_index(color)
116}
117
118fn rgb_to_16_bg(color: Color) -> u8 {
119    40 + rgb_to_16_index(color)
120}
121
122fn rgb_to_16_index(color: Color) -> u8 {
123    let Color(r, g, b) = color;
124    let intensity = (r as u16 + g as u16 + b as u16) / 3;
125
126    // Simple nearest-match to ANSI 8 colors + bright variants
127    let is_bright = intensity > 128;
128    let idx = if r > 128 && g < 128 && b < 128 {
129        1 // red
130    } else if r < 128 && g > 128 && b < 128 {
131        2 // green
132    } else if r > 128 && g > 128 && b < 128 {
133        3 // yellow
134    } else if r < 128 && g < 128 && b > 128 {
135        4 // blue
136    } else if r > 128 && g < 128 && b > 128 {
137        5 // magenta
138    } else if r < 128 && g > 128 && b > 128 {
139        6 // cyan
140    } else {
141        0 // black/white
142    };
143
144    if is_bright && idx == 0 {
145        7 // bright white
146    } else if is_bright {
147        idx + 60 // bright variant (90-97)
148    } else {
149        idx
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use std::{
156        env,
157        sync::Mutex,
158    };
159
160    use super::*;
161
162    // Environment variables are process-wide, so env-mutating tests must be
163    // serialized even when cargo runs other tests in parallel.
164    static ENV_LOCK: Mutex<()> = Mutex::new(());
165
166    fn with_envs<F>(vars: &[(&str, Option<&str>)], f: F)
167    where
168        F: FnOnce(), {
169        let _guard = ENV_LOCK.lock().unwrap();
170        let originals: Vec<(String, Option<String>)> = vars
171            .iter()
172            .map(|(key, _)| ((*key).to_string(), env::var(key).ok()))
173            .collect();
174        for (key, value) in vars {
175            match value {
176                // SAFETY: tests that mutate env vars are serialized via ENV_LOCK.
177                | Some(v) => unsafe { env::set_var(key, v) },
178                // SAFETY: same as above — serialized env mutation.
179                | None => unsafe { env::remove_var(key) },
180            }
181        }
182        f();
183        for (key, original) in originals {
184            match original {
185                // SAFETY: serialized env mutation.
186                | Some(v) => unsafe { env::set_var(&key, v) },
187                // SAFETY: serialized env mutation.
188                | None => unsafe { env::remove_var(&key) },
189            }
190        }
191    }
192
193    #[test]
194    fn truecolor_fg() {
195        let s = fg(Color::SUNBEAM_ORANGE, ColorMode::TrueColor);
196        assert_eq!(s, "\x1b[38;2;250;82;15m");
197    }
198
199    #[test]
200    fn truecolor_bg() {
201        let s = bg(Color::WARM_IVORY, ColorMode::TrueColor);
202        assert_eq!(s, "\x1b[48;2;255;250;237m");
203    }
204
205    #[test]
206    fn color256_produces_valid_codes() {
207        let s = fg(Color::SUNBEAM_ORANGE, ColorMode::Color256);
208        assert!(s.starts_with("\x1b[38;5;"));
209        assert!(s.ends_with('m'));
210    }
211
212    #[test]
213    fn color256_bg_produces_valid_codes() {
214        let s = bg(Color::SUNBEAM_ORANGE, ColorMode::Color256);
215        assert!(s.starts_with("\x1b[48;5;"));
216        assert!(s.ends_with('m'));
217    }
218
219    #[test]
220    fn basic16_produces_valid_codes() {
221        let s = fg(Color::WHITE, ColorMode::Basic16);
222        assert!(s.starts_with("\x1b["));
223        assert!(s.ends_with('m'));
224    }
225
226    #[test]
227    fn basic16_bg_produces_valid_codes() {
228        let s = bg(Color::WHITE, ColorMode::Basic16);
229        assert!(s.starts_with("\x1b["));
230        assert!(s.ends_with('m'));
231    }
232
233    #[test]
234    fn detect_respects_photon_color_mode() {
235        with_envs(&[("PHOTON_COLOR_MODE", Some("256"))], || {
236            assert_eq!(ColorMode::detect(), ColorMode::Color256);
237        });
238        with_envs(&[("PHOTON_COLOR_MODE", Some("basic"))], || {
239            assert_eq!(ColorMode::detect(), ColorMode::Basic16);
240        });
241        with_envs(&[("PHOTON_COLOR_MODE", Some("truecolor"))], || {
242            assert_eq!(ColorMode::detect(), ColorMode::TrueColor);
243        });
244        with_envs(
245            &[
246                ("PHOTON_COLOR_MODE", Some("unknown")),
247                ("COLORTERM", None),
248                ("TERM", None),
249            ],
250            || {
251                assert_eq!(ColorMode::detect(), ColorMode::TrueColor);
252            },
253        );
254    }
255
256    #[test]
257    fn detect_respects_colorterm() {
258        with_envs(
259            &[
260                ("PHOTON_COLOR_MODE", None),
261                ("COLORTERM", Some("truecolor")),
262                ("TERM", None),
263            ],
264            || {
265                assert_eq!(ColorMode::detect(), ColorMode::TrueColor);
266            },
267        );
268        with_envs(
269            &[
270                ("PHOTON_COLOR_MODE", None),
271                ("COLORTERM", Some("24bit")),
272                ("TERM", None),
273            ],
274            || {
275                assert_eq!(ColorMode::detect(), ColorMode::TrueColor);
276            },
277        );
278    }
279
280    #[test]
281    fn detect_respects_term() {
282        with_envs(
283            &[
284                ("PHOTON_COLOR_MODE", None),
285                ("COLORTERM", None),
286                ("TERM", Some("xterm-256color")),
287            ],
288            || {
289                assert_eq!(ColorMode::detect(), ColorMode::Color256);
290            },
291        );
292        with_envs(
293            &[
294                ("PHOTON_COLOR_MODE", None),
295                ("COLORTERM", None),
296                ("TERM", Some("xterm")),
297            ],
298            || {
299                assert_eq!(ColorMode::detect(), ColorMode::TrueColor);
300            },
301        );
302    }
303
304    #[test]
305    fn rgb_to_256_grayscale() {
306        assert_eq!(rgb_to_256(Color(0, 0, 0)), 16);
307        assert_eq!(rgb_to_256(Color(255, 255, 255)), 231);
308        assert_eq!(rgb_to_256(Color(128, 128, 128)), 232 + ((128 - 8) / 10));
309    }
310
311    #[test]
312    fn rgb_to_256_color_cube() {
313        // SUNBEAM_ORANGE is not grayscale, so it should map into the 6x6x6 cube.
314        let idx = rgb_to_256(Color::SUNBEAM_ORANGE);
315        assert!((16..=231).contains(&idx));
316    }
317
318    #[test]
319    fn closest_cube_level_branches() {
320        assert_eq!(closest_cube_level(0), 0);
321        assert_eq!(closest_cube_level(47), 0);
322        assert_eq!(closest_cube_level(48), 1);
323        assert_eq!(closest_cube_level(114), 1);
324        assert_eq!(closest_cube_level(115), 2);
325        assert_eq!(closest_cube_level(154), 2);
326        assert_eq!(closest_cube_level(155), 3);
327        assert_eq!(closest_cube_level(194), 3);
328        assert_eq!(closest_cube_level(195), 4);
329        assert_eq!(closest_cube_level(234), 4);
330        assert_eq!(closest_cube_level(235), 5);
331        assert_eq!(closest_cube_level(255), 5);
332    }
333
334    #[test]
335    fn rgb_to_16_fg_and_bg() {
336        // White maps to bright white (7) for fg, so 37; for bg, 47.
337        assert_eq!(rgb_to_16_fg(Color::WHITE), 37);
338        assert_eq!(rgb_to_16_bg(Color::WHITE), 47);
339    }
340
341    #[test]
342    fn rgb_to_16_index_color_branches() {
343        // Red (average intensity 85, not bright -> idx 1)
344        assert_eq!(rgb_to_16_index(Color(255, 0, 0)), 1);
345        // Green (average intensity 85, not bright -> idx 2)
346        assert_eq!(rgb_to_16_index(Color(0, 255, 0)), 2);
347        // Yellow (average intensity 170, bright -> idx 3 + 60 = 63)
348        assert_eq!(rgb_to_16_index(Color(255, 255, 0)), 63);
349        // Blue (average intensity 85, not bright -> idx 4)
350        assert_eq!(rgb_to_16_index(Color(0, 0, 255)), 4);
351        // Magenta (average intensity 170, bright -> idx 5 + 60 = 65)
352        assert_eq!(rgb_to_16_index(Color(255, 0, 255)), 65);
353        // Cyan (average intensity 170, bright -> idx 6 + 60 = 66)
354        assert_eq!(rgb_to_16_index(Color(0, 255, 255)), 66);
355        // Black (low intensity -> index 0)
356        assert_eq!(rgb_to_16_index(Color(0, 0, 0)), 0);
357        // White (high intensity, idx 0 -> bright white 7)
358        assert_eq!(rgb_to_16_index(Color(255, 255, 255)), 7);
359        // Dim red (r > 128 but average intensity <= 128 -> idx 1)
360        assert_eq!(rgb_to_16_index(Color(192, 0, 0)), 1);
361    }
362}