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