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            if ct == "truecolor" || ct == "24bit" {
39                return ColorMode::TrueColor;
40            }
41        }
42        if let Ok(term) = env::var("TERM") {
43            if term.contains("256color") {
44                return ColorMode::Color256;
45            }
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 super::*;
156
157    #[test]
158    fn truecolor_fg() {
159        let s = fg(Color::SUNBEAM_ORANGE, ColorMode::TrueColor);
160        assert_eq!(s, "\x1b[38;2;250;82;15m");
161    }
162
163    #[test]
164    fn truecolor_bg() {
165        let s = bg(Color::WARM_IVORY, ColorMode::TrueColor);
166        assert_eq!(s, "\x1b[48;2;255;250;237m");
167    }
168
169    #[test]
170    fn color256_produces_valid_codes() {
171        let s = fg(Color::SUNBEAM_ORANGE, ColorMode::Color256);
172        assert!(s.starts_with("\x1b[38;5;"));
173        assert!(s.ends_with('m'));
174    }
175
176    #[test]
177    fn basic16_produces_valid_codes() {
178        let s = fg(Color::WHITE, ColorMode::Basic16);
179        assert!(s.starts_with("\x1b["));
180        assert!(s.ends_with('m'));
181    }
182}