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
50#[inline]
51fn to_linear(c: f32) -> f32 {
52    if c <= 0.04045 {
53        c / 12.92
54    } else {
55        ((c + 0.055) / 1.055).powf(2.4)
56    }
57}
58
59impl Color {
60    /// Resolve to `(r, g, b)` for luminance and blending operations.
61    ///
62    /// Named colors map to their typical terminal palette values.
63    /// [`Color::Reset`] maps to black; [`Color::Indexed`] maps to the xterm-256 palette.
64    fn to_rgb(self) -> (u8, u8, u8) {
65        match self {
66            Color::Rgb(r, g, b) => (r, g, b),
67            Color::Black => (0, 0, 0),
68            Color::Red => (205, 49, 49),
69            Color::Green => (13, 188, 121),
70            Color::Yellow => (229, 229, 16),
71            Color::Blue => (36, 114, 200),
72            Color::Magenta => (188, 63, 188),
73            Color::Cyan => (17, 168, 205),
74            Color::White => (229, 229, 229),
75            Color::DarkGray => (128, 128, 128),
76            Color::LightRed => (255, 0, 0),
77            Color::LightGreen => (0, 255, 0),
78            Color::LightYellow => (255, 255, 0),
79            Color::LightBlue => (0, 0, 255),
80            Color::LightMagenta => (255, 0, 255),
81            Color::LightCyan => (0, 255, 255),
82            Color::LightWhite => (255, 255, 255),
83            Color::Reset => (0, 0, 0),
84            Color::Indexed(idx) => xterm256_to_rgb(idx),
85        }
86    }
87
88    /// Compute relative luminance using ITU-R BT.709 coefficients.
89    ///
90    /// Returns a value in `[0.0, 1.0]` where 0 is darkest and 1 is brightest.
91    /// Use this to determine whether text on a given background should be
92    /// light or dark.
93    ///
94    /// # Example
95    ///
96    /// ```
97    /// use slt::Color;
98    ///
99    /// let dark = Color::Rgb(30, 30, 46);
100    /// assert!(dark.luminance() < 0.15);
101    ///
102    /// let light = Color::Rgb(205, 214, 244);
103    /// assert!(light.luminance() > 0.6);
104    /// ```
105    pub fn luminance(self) -> f32 {
106        let (r, g, b) = self.to_rgb();
107        let rf = to_linear(r as f32 / 255.0);
108        let gf = to_linear(g as f32 / 255.0);
109        let bf = to_linear(b as f32 / 255.0);
110        0.2126 * rf + 0.7152 * gf + 0.0722 * bf
111    }
112
113    /// Return a contrasting foreground color for the given background.
114    ///
115    /// Uses the WCAG 2.1 relative luminance threshold (0.179) to decide
116    /// between white and black text. For theme-aware contrast, prefer using
117    /// this over hardcoding `theme.bg` as the foreground.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use slt::Color;
123    ///
124    /// let bg = Color::Rgb(189, 147, 249); // Dracula purple
125    /// let fg = Color::contrast_fg(bg);
126    /// // Dracula purple → white (WCAG luminance 0.385 < 0.179 threshold)
127    /// ```
128    pub fn contrast_fg(bg: Color) -> Color {
129        if bg.luminance() > 0.179 {
130            Color::Rgb(0, 0, 0)
131        } else {
132            Color::Rgb(255, 255, 255)
133        }
134    }
135
136    /// Blend this color over another with the given alpha.
137    ///
138    /// `alpha` is in `[0.0, 1.0]` where 0.0 returns `other` unchanged and
139    /// 1.0 returns `self` unchanged. Both colors are resolved to RGB.
140    ///
141    /// # Example
142    ///
143    /// ```
144    /// use slt::Color;
145    ///
146    /// let white = Color::Rgb(255, 255, 255);
147    /// let black = Color::Rgb(0, 0, 0);
148    /// let gray = white.blend(black, 0.5);
149    /// // ≈ Rgb(128, 128, 128)
150    /// ```
151    pub fn blend(self, other: Color, alpha: f32) -> Color {
152        let alpha = alpha.clamp(0.0, 1.0);
153        let (r1, g1, b1) = self.to_rgb();
154        let (r2, g2, b2) = other.to_rgb();
155        let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
156        let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
157        let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
158        Color::Rgb(r, g, b)
159    }
160
161    /// Lighten this color by the given amount (0.0–1.0).
162    ///
163    /// Blends toward white. `amount = 0.0` returns the original color;
164    /// `amount = 1.0` returns white.
165    pub fn lighten(self, amount: f32) -> Color {
166        Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
167    }
168
169    /// Darken this color by the given amount (0.0–1.0).
170    ///
171    /// Blends toward black. `amount = 0.0` returns the original color;
172    /// `amount = 1.0` returns black.
173    pub fn darken(self, amount: f32) -> Color {
174        Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
175    }
176
177    /// Compute the WCAG 2.1 contrast ratio between two colors.
178    ///
179    /// Returns a value >= 1.0. A ratio >= 4.5 meets WCAG AA for normal text;
180    /// >= 3.0 meets AA for large text.
181    ///
182    /// # Example
183    ///
184    /// ```
185    /// use slt::Color;
186    ///
187    /// let ratio = Color::contrast_ratio(Color::White, Color::Black);
188    /// assert!(ratio > 15.0);
189    /// ```
190    pub fn contrast_ratio(a: Color, b: Color) -> f32 {
191        let la = a.luminance() + 0.05;
192        let lb = b.luminance() + 0.05;
193        if la > lb {
194            la / lb
195        } else {
196            lb / la
197        }
198    }
199
200    /// Returns `true` if the contrast ratio between two colors meets WCAG AA
201    /// for normal text (ratio >= 4.5).
202    pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
203        Self::contrast_ratio(fg, bg) >= 4.5
204    }
205
206    /// Downsample this color to fit the given color depth.
207    ///
208    /// - `TrueColor`: returns self unchanged.
209    /// - `EightBit`: converts `Rgb` to the nearest `Indexed` color.
210    /// - `Basic`: converts `Rgb` and `Indexed` to the nearest named color.
211    /// - `NoColor`: returns [`Color::Reset`] — emit no ANSI color at all.
212    ///
213    /// Named colors (`Red`, `Green`, etc.) and `Reset` pass through at
214    /// depths other than `NoColor`.
215    pub fn downsampled(self, depth: ColorDepth) -> Color {
216        match depth {
217            ColorDepth::TrueColor => self,
218            ColorDepth::EightBit => match self {
219                Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
220                other => other,
221            },
222            ColorDepth::Basic => match self {
223                Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
224                Color::Indexed(i) => {
225                    let (r, g, b) = xterm256_to_rgb(i);
226                    rgb_to_ansi16(r, g, b)
227                }
228                other => other,
229            },
230            ColorDepth::NoColor => Color::Reset,
231        }
232    }
233}
234
235/// Terminal color depth capability.
236///
237/// Determines the maximum number of colors a terminal can display.
238/// Use [`ColorDepth::detect`] for automatic detection via environment
239/// variables, or specify explicitly in [`crate::RunConfig`].
240#[non_exhaustive]
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
243pub enum ColorDepth {
244    /// 24-bit true color (16 million colors).
245    TrueColor,
246    /// 256-color palette (xterm-256color).
247    EightBit,
248    /// 16 basic ANSI colors.
249    Basic,
250    /// No color output — every color is downsampled to [`Color::Reset`] and
251    /// the terminal emits no SGR color codes. Selected automatically by
252    /// [`ColorDepth::detect`] when the `NO_COLOR` environment variable is
253    /// set to any non-empty value, per <https://no-color.org>.
254    NoColor,
255}
256
257#[cfg(test)]
258mod color_depth_tests {
259    use super::{Color, ColorDepth};
260
261    #[test]
262    fn no_color_downsamples_everything_to_reset() {
263        assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
264        assert_eq!(
265            Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
266            Color::Reset
267        );
268        assert_eq!(
269            Color::Indexed(44).downsampled(ColorDepth::NoColor),
270            Color::Reset
271        );
272    }
273}
274
275impl ColorDepth {
276    /// Detect the terminal's color depth from environment variables.
277    ///
278    /// Order of precedence:
279    /// 1. `NO_COLOR` (any non-empty value) → [`ColorDepth::NoColor`]
280    /// 2. `COLORTERM=truecolor|24bit` → [`ColorDepth::TrueColor`]
281    /// 3. `TERM` contains `256color` → [`ColorDepth::EightBit`]
282    /// 4. Fallback → [`ColorDepth::Basic`] (16 colors)
283    pub fn detect() -> Self {
284        // https://no-color.org — ANY non-empty value disables color.
285        if std::env::var("NO_COLOR")
286            .ok()
287            .is_some_and(|v| !v.is_empty())
288        {
289            return Self::NoColor;
290        }
291        if let Ok(ct) = std::env::var("COLORTERM") {
292            let ct = ct.to_lowercase();
293            if ct == "truecolor" || ct == "24bit" {
294                return Self::TrueColor;
295            }
296        }
297        if let Ok(term) = std::env::var("TERM") {
298            if term.contains("256color") {
299                return Self::EightBit;
300            }
301        }
302        Self::Basic
303    }
304}
305
306fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
307    if r == g && g == b {
308        if r < 8 {
309            return 16;
310        }
311        if r >= 248 {
312            return 231;
313        }
314        return 232 + (((r as u16 - 8) * 24 / 240) as u8);
315    }
316
317    let ri = if r < 48 {
318        0
319    } else {
320        ((r as u16 - 35) / 40) as u8
321    };
322    let gi = if g < 48 {
323        0
324    } else {
325        ((g as u16 - 35) / 40) as u8
326    };
327    let bi = if b < 48 {
328        0
329    } else {
330        ((b as u16 - 35) / 40) as u8
331    };
332    16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
333}
334
335fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
336    let lum = 0.2126 * to_linear(r as f32 / 255.0)
337        + 0.7152 * to_linear(g as f32 / 255.0)
338        + 0.0722 * to_linear(b as f32 / 255.0);
339
340    let max = r.max(g).max(b);
341    let min = r.min(g).min(b);
342    let saturation = if max == 0 {
343        0.0
344    } else {
345        (max - min) as f32 / max as f32
346    };
347
348    if saturation < 0.2 {
349        // Grayscale: classify purely by luminance.
350        return match lum {
351            l if l < 0.05 => Color::Black,
352            l if l < 0.25 => Color::DarkGray,
353            l if l < 0.7 => Color::White,
354            _ => Color::White, // LightWhite is not available in Color enum
355        };
356    }
357
358    // For chromatic colors the "bright" variant of each ANSI hue (e.g. xterm
359    // LightRed = `(255, 85, 85)`) is distinguished by the minimum channel
360    // being lifted off zero — it's a brighter, partially desaturated version
361    // of the same hue. A perfectly saturated primary (`min == 0`) like pure
362    // red `(255, 0, 0)` should map to the standard color. We pick the bright
363    // variant only when both the value is high and the color is desaturated
364    // enough to look "lifted".
365    let bright = max >= 200 && min >= 64;
366
367    let rf = r as f32;
368    let gf = g as f32;
369    let bf = b as f32;
370
371    if rf >= gf && rf >= bf {
372        if gf > bf * 1.5 {
373            if bright {
374                Color::LightYellow
375            } else {
376                Color::Yellow
377            }
378        } else if bf > gf * 1.5 {
379            if bright {
380                Color::LightMagenta
381            } else {
382                Color::Magenta
383            }
384        } else if bright {
385            Color::LightRed
386        } else {
387            Color::Red
388        }
389    } else if gf >= rf && gf >= bf {
390        if bf > rf * 1.5 {
391            if bright {
392                Color::LightCyan
393            } else {
394                Color::Cyan
395            }
396        } else if bright {
397            Color::LightGreen
398        } else {
399            Color::Green
400        }
401    } else if rf > gf * 1.5 {
402        if bright {
403            Color::LightMagenta
404        } else {
405            Color::Magenta
406        }
407    } else if gf > rf * 1.5 {
408        if bright {
409            Color::LightCyan
410        } else {
411            Color::Cyan
412        }
413    } else if bright {
414        Color::LightBlue
415    } else {
416        Color::Blue
417    }
418}
419
420fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
421    match idx {
422        0 => (0, 0, 0),
423        1 => (128, 0, 0),
424        2 => (0, 128, 0),
425        3 => (128, 128, 0),
426        4 => (0, 0, 128),
427        5 => (128, 0, 128),
428        6 => (0, 128, 128),
429        7 => (192, 192, 192),
430        8 => (128, 128, 128),
431        9 => (255, 0, 0),
432        10 => (0, 255, 0),
433        11 => (255, 255, 0),
434        12 => (0, 0, 255),
435        13 => (255, 0, 255),
436        14 => (0, 255, 255),
437        15 => (255, 255, 255),
438        16..=231 => {
439            let n = idx - 16;
440            let b_idx = n % 6;
441            let g_idx = (n / 6) % 6;
442            let r_idx = n / 36;
443            let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
444            (to_val(r_idx), to_val(g_idx), to_val(b_idx))
445        }
446        232..=255 => {
447            let v = 8 + 10 * (idx - 232);
448            (v, v, v)
449        }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    #![allow(clippy::unwrap_used)]
456    use super::*;
457
458    #[test]
459    fn blend_halfway_rounds_to_128() {
460        assert_eq!(
461            Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
462            Color::Rgb(128, 128, 128)
463        );
464    }
465
466    #[test]
467    fn contrast_ratio_white_on_black_is_high() {
468        let ratio = Color::contrast_ratio(Color::White, Color::Black);
469        assert!(ratio > 15.0);
470    }
471
472    #[test]
473    fn contrast_ratio_same_color_is_one() {
474        let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
475        assert!((ratio - 1.0).abs() < 0.01);
476    }
477
478    #[test]
479    fn meets_contrast_aa_white_on_black() {
480        assert!(Color::meets_contrast_aa(Color::White, Color::Black));
481    }
482
483    #[test]
484    fn meets_contrast_aa_low_contrast_fails() {
485        assert!(!Color::meets_contrast_aa(
486            Color::Rgb(180, 180, 180),
487            Color::Rgb(200, 200, 200)
488        ));
489    }
490
491    // --- regression: issue #104 rgb_to_ansi256 overflow at r=g=b=248 ---
492
493    #[test]
494    fn rgb_to_ansi256_no_overflow_full_range() {
495        // 256^3 exhaustive — guarantees no panic in debug or release builds
496        for r in 0u8..=255 {
497            for g in 0u8..=255 {
498                for b in 0u8..=255 {
499                    let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
500                }
501            }
502        }
503    }
504
505    #[test]
506    fn rgb_248_maps_to_231() {
507        assert_eq!(
508            Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
509            Color::Indexed(231)
510        );
511    }
512
513    // --- regression: issue #105 WCAG luminance sRGB gamma ---
514
515    #[test]
516    fn luminance_dracula_purple_wcag() {
517        let l = Color::Rgb(189, 147, 249).luminance();
518        assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
519    }
520
521    #[test]
522    fn contrast_aa_dracula_pair() {
523        let p = Color::Rgb(189, 147, 249);
524        let bg = Color::Rgb(40, 42, 54);
525        assert!(Color::meets_contrast_aa(p, bg));
526        let r = Color::contrast_ratio(p, bg);
527        assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
528    }
529
530    #[test]
531    fn contrast_white_on_black_is_21() {
532        let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
533        assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
534    }
535
536    // --- regression: issue #107 rgb_to_ansi16 includes bright (8-15) colors ---
537
538    #[test]
539    fn rgb_to_ansi16_bright_variants() {
540        // bright red → LightRed
541        assert_eq!(
542            Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
543            Color::LightRed
544        );
545        // dark red → Red
546        assert_eq!(
547            Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
548            Color::Red
549        );
550        // bright gray → White
551        assert_eq!(
552            Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
553            Color::White
554        );
555        // dark gray → DarkGray
556        assert_eq!(
557            Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
558            Color::DarkGray
559        );
560    }
561}