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