Skip to main content

slt/style/
color.rs

1/// Terminal color.
2///
3/// Covers the standard 16 named colors, 256-color palette indices, and
4/// 24-bit RGB true color. Use [`Color::Reset`] to restore the terminal's
5/// default foreground or background.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum Color {
9    /// Reset to the terminal's default color.
10    Reset,
11    /// Standard black (color index 0).
12    Black,
13    /// Standard red (color index 1).
14    Red,
15    /// Standard green (color index 2).
16    Green,
17    /// Standard yellow (color index 3).
18    Yellow,
19    /// Standard blue (color index 4).
20    Blue,
21    /// Standard magenta (color index 5).
22    Magenta,
23    /// Standard cyan (color index 6).
24    Cyan,
25    /// Standard white (color index 7).
26    White,
27    /// 24-bit true color.
28    Rgb(u8, u8, u8),
29    /// 256-color palette index.
30    Indexed(u8),
31}
32
33impl Color {
34    /// Resolve to `(r, g, b)` for luminance and blending operations.
35    ///
36    /// Named colors map to their typical terminal palette values.
37    /// [`Color::Reset`] maps to black; [`Color::Indexed`] maps to the xterm-256 palette.
38    fn to_rgb(self) -> (u8, u8, u8) {
39        match self {
40            Color::Rgb(r, g, b) => (r, g, b),
41            Color::Black => (0, 0, 0),
42            Color::Red => (205, 49, 49),
43            Color::Green => (13, 188, 121),
44            Color::Yellow => (229, 229, 16),
45            Color::Blue => (36, 114, 200),
46            Color::Magenta => (188, 63, 188),
47            Color::Cyan => (17, 168, 205),
48            Color::White => (229, 229, 229),
49            Color::Reset => (0, 0, 0),
50            Color::Indexed(idx) => xterm256_to_rgb(idx),
51        }
52    }
53
54    /// Compute relative luminance using ITU-R BT.709 coefficients.
55    ///
56    /// Returns a value in `[0.0, 1.0]` where 0 is darkest and 1 is brightest.
57    /// Use this to determine whether text on a given background should be
58    /// light or dark.
59    ///
60    /// # Example
61    ///
62    /// ```
63    /// use slt::Color;
64    ///
65    /// let dark = Color::Rgb(30, 30, 46);
66    /// assert!(dark.luminance() < 0.15);
67    ///
68    /// let light = Color::Rgb(205, 214, 244);
69    /// assert!(light.luminance() > 0.6);
70    /// ```
71    pub fn luminance(self) -> f32 {
72        let (r, g, b) = self.to_rgb();
73        let rf = r as f32 / 255.0;
74        let gf = g as f32 / 255.0;
75        let bf = b as f32 / 255.0;
76        0.2126 * rf + 0.7152 * gf + 0.0722 * bf
77    }
78
79    /// Return a contrasting foreground color for the given background.
80    ///
81    /// Uses the BT.709 luminance threshold (0.5) to decide between white
82    /// and black text. For theme-aware contrast, prefer using this over
83    /// hardcoding `theme.bg` as the foreground.
84    ///
85    /// # Example
86    ///
87    /// ```
88    /// use slt::Color;
89    ///
90    /// let bg = Color::Rgb(189, 147, 249); // Dracula purple
91    /// let fg = Color::contrast_fg(bg);
92    /// // Purple is mid-bright → returns black for readable text
93    /// ```
94    pub fn contrast_fg(bg: Color) -> Color {
95        if bg.luminance() > 0.5 {
96            Color::Rgb(0, 0, 0)
97        } else {
98            Color::Rgb(255, 255, 255)
99        }
100    }
101
102    /// Blend this color over another with the given alpha.
103    ///
104    /// `alpha` is in `[0.0, 1.0]` where 0.0 returns `other` unchanged and
105    /// 1.0 returns `self` unchanged. Both colors are resolved to RGB.
106    ///
107    /// # Example
108    ///
109    /// ```
110    /// use slt::Color;
111    ///
112    /// let white = Color::Rgb(255, 255, 255);
113    /// let black = Color::Rgb(0, 0, 0);
114    /// let gray = white.blend(black, 0.5);
115    /// // ≈ Rgb(128, 128, 128)
116    /// ```
117    pub fn blend(self, other: Color, alpha: f32) -> Color {
118        let alpha = alpha.clamp(0.0, 1.0);
119        let (r1, g1, b1) = self.to_rgb();
120        let (r2, g2, b2) = other.to_rgb();
121        let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)) as u8;
122        let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)) as u8;
123        let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)) as u8;
124        Color::Rgb(r, g, b)
125    }
126
127    /// Lighten this color by the given amount (0.0–1.0).
128    ///
129    /// Blends toward white. `amount = 0.0` returns the original color;
130    /// `amount = 1.0` returns white.
131    pub fn lighten(self, amount: f32) -> Color {
132        Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
133    }
134
135    /// Darken this color by the given amount (0.0–1.0).
136    ///
137    /// Blends toward black. `amount = 0.0` returns the original color;
138    /// `amount = 1.0` returns black.
139    pub fn darken(self, amount: f32) -> Color {
140        Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
141    }
142
143    /// Downsample this color to fit the given color depth.
144    ///
145    /// - `TrueColor`: returns self unchanged.
146    /// - `EightBit`: converts `Rgb` to the nearest `Indexed` color.
147    /// - `Basic`: converts `Rgb` and `Indexed` to the nearest named color.
148    ///
149    /// Named colors (`Red`, `Green`, etc.) and `Reset` pass through all depths.
150    pub fn downsampled(self, depth: ColorDepth) -> Color {
151        match depth {
152            ColorDepth::TrueColor => self,
153            ColorDepth::EightBit => match self {
154                Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
155                other => other,
156            },
157            ColorDepth::Basic => match self {
158                Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
159                Color::Indexed(i) => {
160                    let (r, g, b) = xterm256_to_rgb(i);
161                    rgb_to_ansi16(r, g, b)
162                }
163                other => other,
164            },
165        }
166    }
167}
168
169/// Terminal color depth capability.
170///
171/// Determines the maximum number of colors a terminal can display.
172/// Use [`ColorDepth::detect`] for automatic detection via environment
173/// variables, or specify explicitly in [`RunConfig`].
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
175#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
176pub enum ColorDepth {
177    /// 24-bit true color (16 million colors).
178    TrueColor,
179    /// 256-color palette (xterm-256color).
180    EightBit,
181    /// 16 basic ANSI colors.
182    Basic,
183}
184
185impl ColorDepth {
186    /// Detect the terminal's color depth from environment variables.
187    ///
188    /// Checks `$COLORTERM` for `truecolor`/`24bit`, then `$TERM` for
189    /// `256color`. Falls back to `Basic` (16 colors) if neither is set.
190    pub fn detect() -> Self {
191        if let Ok(ct) = std::env::var("COLORTERM") {
192            let ct = ct.to_lowercase();
193            if ct == "truecolor" || ct == "24bit" {
194                return Self::TrueColor;
195            }
196        }
197        if let Ok(term) = std::env::var("TERM") {
198            if term.contains("256color") {
199                return Self::EightBit;
200            }
201        }
202        Self::Basic
203    }
204}
205
206fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
207    if r == g && g == b {
208        if r < 8 {
209            return 16;
210        }
211        if r > 248 {
212            return 231;
213        }
214        return 232 + (((r as u16 - 8) * 24 / 240) as u8);
215    }
216
217    let ri = if r < 48 {
218        0
219    } else {
220        ((r as u16 - 35) / 40) as u8
221    };
222    let gi = if g < 48 {
223        0
224    } else {
225        ((g as u16 - 35) / 40) as u8
226    };
227    let bi = if b < 48 {
228        0
229    } else {
230        ((b as u16 - 35) / 40) as u8
231    };
232    16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
233}
234
235fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
236    let lum =
237        0.2126 * (r as f32 / 255.0) + 0.7152 * (g as f32 / 255.0) + 0.0722 * (b as f32 / 255.0);
238
239    let max = r.max(g).max(b);
240    let min = r.min(g).min(b);
241    let saturation = if max == 0 {
242        0.0
243    } else {
244        (max - min) as f32 / max as f32
245    };
246
247    if saturation < 0.2 {
248        return if lum < 0.15 {
249            Color::Black
250        } else {
251            Color::White
252        };
253    }
254
255    let rf = r as f32;
256    let gf = g as f32;
257    let bf = b as f32;
258
259    if rf >= gf && rf >= bf {
260        if gf > bf * 1.5 {
261            Color::Yellow
262        } else if bf > gf * 1.5 {
263            Color::Magenta
264        } else {
265            Color::Red
266        }
267    } else if gf >= rf && gf >= bf {
268        if bf > rf * 1.5 {
269            Color::Cyan
270        } else {
271            Color::Green
272        }
273    } else if rf > gf * 1.5 {
274        Color::Magenta
275    } else if gf > rf * 1.5 {
276        Color::Cyan
277    } else {
278        Color::Blue
279    }
280}
281
282fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
283    match idx {
284        0 => (0, 0, 0),
285        1 => (128, 0, 0),
286        2 => (0, 128, 0),
287        3 => (128, 128, 0),
288        4 => (0, 0, 128),
289        5 => (128, 0, 128),
290        6 => (0, 128, 128),
291        7 => (192, 192, 192),
292        8 => (128, 128, 128),
293        9 => (255, 0, 0),
294        10 => (0, 255, 0),
295        11 => (255, 255, 0),
296        12 => (0, 0, 255),
297        13 => (255, 0, 255),
298        14 => (0, 255, 255),
299        15 => (255, 255, 255),
300        16..=231 => {
301            let n = idx - 16;
302            let b_idx = n % 6;
303            let g_idx = (n / 6) % 6;
304            let r_idx = n / 36;
305            let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
306            (to_val(r_idx), to_val(g_idx), to_val(b_idx))
307        }
308        232..=255 => {
309            let v = 8 + 10 * (idx - 232);
310            (v, v, v)
311        }
312    }
313}