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    /// Compute the WCAG 2.1 contrast ratio between two colors.
169    ///
170    /// Returns a value >= 1.0. A ratio >= 4.5 meets WCAG AA for normal text;
171    /// >= 3.0 meets AA for large text.
172    ///
173    /// # Example
174    ///
175    /// ```
176    /// use slt::Color;
177    ///
178    /// let ratio = Color::contrast_ratio(Color::White, Color::Black);
179    /// assert!(ratio > 15.0);
180    /// ```
181    pub fn contrast_ratio(a: Color, b: Color) -> f32 {
182        let la = a.luminance() + 0.05;
183        let lb = b.luminance() + 0.05;
184        if la > lb {
185            la / lb
186        } else {
187            lb / la
188        }
189    }
190
191    /// Returns `true` if the contrast ratio between two colors meets WCAG AA
192    /// for normal text (ratio >= 4.5).
193    pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
194        Self::contrast_ratio(fg, bg) >= 4.5
195    }
196
197    /// Downsample this color to fit the given color depth.
198    ///
199    /// - `TrueColor`: returns self unchanged.
200    /// - `EightBit`: converts `Rgb` to the nearest `Indexed` color.
201    /// - `Basic`: converts `Rgb` and `Indexed` to the nearest named color.
202    /// - `NoColor`: returns [`Color::Reset`] — emit no ANSI color at all.
203    ///
204    /// Named colors (`Red`, `Green`, etc.) and `Reset` pass through at
205    /// depths other than `NoColor`.
206    pub fn downsampled(self, depth: ColorDepth) -> Color {
207        match depth {
208            ColorDepth::TrueColor => self,
209            ColorDepth::EightBit => match self {
210                Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
211                other => other,
212            },
213            ColorDepth::Basic => match self {
214                Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
215                Color::Indexed(i) => {
216                    let (r, g, b) = xterm256_to_rgb(i);
217                    rgb_to_ansi16(r, g, b)
218                }
219                other => other,
220            },
221            ColorDepth::NoColor => Color::Reset,
222        }
223    }
224}
225
226/// Terminal color depth capability.
227///
228/// Determines the maximum number of colors a terminal can display.
229/// Use [`ColorDepth::detect`] for automatic detection via environment
230/// variables, or specify explicitly in [`crate::RunConfig`].
231#[non_exhaustive]
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
233#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
234pub enum ColorDepth {
235    /// 24-bit true color (16 million colors).
236    TrueColor,
237    /// 256-color palette (xterm-256color).
238    EightBit,
239    /// 16 basic ANSI colors.
240    Basic,
241    /// No color output — every color is downsampled to [`Color::Reset`] and
242    /// the terminal emits no SGR color codes. Selected automatically by
243    /// [`ColorDepth::detect`] when the `NO_COLOR` environment variable is
244    /// set to any non-empty value, per <https://no-color.org>.
245    NoColor,
246}
247
248#[cfg(test)]
249mod color_depth_tests {
250    use super::{Color, ColorDepth};
251
252    #[test]
253    fn no_color_downsamples_everything_to_reset() {
254        assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
255        assert_eq!(
256            Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
257            Color::Reset
258        );
259        assert_eq!(
260            Color::Indexed(44).downsampled(ColorDepth::NoColor),
261            Color::Reset
262        );
263    }
264}
265
266impl ColorDepth {
267    /// Detect the terminal's color depth from environment variables.
268    ///
269    /// Order of precedence:
270    /// 1. `NO_COLOR` (any non-empty value) → [`ColorDepth::NoColor`]
271    /// 2. `COLORTERM=truecolor|24bit` → [`ColorDepth::TrueColor`]
272    /// 3. `TERM` contains `256color` → [`ColorDepth::EightBit`]
273    /// 4. Fallback → [`ColorDepth::Basic`] (16 colors)
274    pub fn detect() -> Self {
275        // https://no-color.org — ANY non-empty value disables color.
276        if std::env::var("NO_COLOR")
277            .ok()
278            .is_some_and(|v| !v.is_empty())
279        {
280            return Self::NoColor;
281        }
282        if let Ok(ct) = std::env::var("COLORTERM") {
283            let ct = ct.to_lowercase();
284            if ct == "truecolor" || ct == "24bit" {
285                return Self::TrueColor;
286            }
287        }
288        if let Ok(term) = std::env::var("TERM") {
289            if term.contains("256color") {
290                return Self::EightBit;
291            }
292        }
293        Self::Basic
294    }
295}
296
297fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
298    if r == g && g == b {
299        if r < 8 {
300            return 16;
301        }
302        if r > 248 {
303            return 231;
304        }
305        return 232 + (((r as u16 - 8) * 24 / 240) as u8);
306    }
307
308    let ri = if r < 48 {
309        0
310    } else {
311        ((r as u16 - 35) / 40) as u8
312    };
313    let gi = if g < 48 {
314        0
315    } else {
316        ((g as u16 - 35) / 40) as u8
317    };
318    let bi = if b < 48 {
319        0
320    } else {
321        ((b as u16 - 35) / 40) as u8
322    };
323    16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
324}
325
326fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
327    let lum =
328        0.2126 * (r as f32 / 255.0) + 0.7152 * (g as f32 / 255.0) + 0.0722 * (b as f32 / 255.0);
329
330    let max = r.max(g).max(b);
331    let min = r.min(g).min(b);
332    let saturation = if max == 0 {
333        0.0
334    } else {
335        (max - min) as f32 / max as f32
336    };
337
338    if saturation < 0.2 {
339        return if lum < 0.15 {
340            Color::Black
341        } else {
342            Color::White
343        };
344    }
345
346    let rf = r as f32;
347    let gf = g as f32;
348    let bf = b as f32;
349
350    if rf >= gf && rf >= bf {
351        if gf > bf * 1.5 {
352            Color::Yellow
353        } else if bf > gf * 1.5 {
354            Color::Magenta
355        } else {
356            Color::Red
357        }
358    } else if gf >= rf && gf >= bf {
359        if bf > rf * 1.5 {
360            Color::Cyan
361        } else {
362            Color::Green
363        }
364    } else if rf > gf * 1.5 {
365        Color::Magenta
366    } else if gf > rf * 1.5 {
367        Color::Cyan
368    } else {
369        Color::Blue
370    }
371}
372
373fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
374    match idx {
375        0 => (0, 0, 0),
376        1 => (128, 0, 0),
377        2 => (0, 128, 0),
378        3 => (128, 128, 0),
379        4 => (0, 0, 128),
380        5 => (128, 0, 128),
381        6 => (0, 128, 128),
382        7 => (192, 192, 192),
383        8 => (128, 128, 128),
384        9 => (255, 0, 0),
385        10 => (0, 255, 0),
386        11 => (255, 255, 0),
387        12 => (0, 0, 255),
388        13 => (255, 0, 255),
389        14 => (0, 255, 255),
390        15 => (255, 255, 255),
391        16..=231 => {
392            let n = idx - 16;
393            let b_idx = n % 6;
394            let g_idx = (n / 6) % 6;
395            let r_idx = n / 36;
396            let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
397            (to_val(r_idx), to_val(g_idx), to_val(b_idx))
398        }
399        232..=255 => {
400            let v = 8 + 10 * (idx - 232);
401            (v, v, v)
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn blend_halfway_rounds_to_128() {
412        assert_eq!(
413            Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
414            Color::Rgb(128, 128, 128)
415        );
416    }
417
418    #[test]
419    fn contrast_ratio_white_on_black_is_high() {
420        let ratio = Color::contrast_ratio(Color::White, Color::Black);
421        assert!(ratio > 15.0);
422    }
423
424    #[test]
425    fn contrast_ratio_same_color_is_one() {
426        let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
427        assert!((ratio - 1.0).abs() < 0.01);
428    }
429
430    #[test]
431    fn meets_contrast_aa_white_on_black() {
432        assert!(Color::meets_contrast_aa(Color::White, Color::Black));
433    }
434
435    #[test]
436    fn meets_contrast_aa_low_contrast_fails() {
437        assert!(!Color::meets_contrast_aa(
438            Color::Rgb(180, 180, 180),
439            Color::Rgb(200, 200, 200)
440        ));
441    }
442}