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)]
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
49#[inline]
50fn to_linear(c: f32) -> f32 {
51    if c <= 0.04045 {
52        c / 12.92
53    } else {
54        ((c + 0.055) / 1.055).powf(2.4)
55    }
56}
57
58impl Color {
59    /// Resolve to `(r, g, b)` for luminance and blending operations.
60    ///
61    /// Named colors map to their typical terminal palette values.
62    /// [`Color::Reset`] maps to black; [`Color::Indexed`] maps to the xterm-256 palette.
63    pub(crate) fn to_rgb(self) -> (u8, u8, u8) {
64        match self {
65            Color::Rgb(r, g, b) => (r, g, b),
66            Color::Black => (0, 0, 0),
67            Color::Red => (205, 49, 49),
68            Color::Green => (13, 188, 121),
69            Color::Yellow => (229, 229, 16),
70            Color::Blue => (36, 114, 200),
71            Color::Magenta => (188, 63, 188),
72            Color::Cyan => (17, 168, 205),
73            Color::White => (229, 229, 229),
74            Color::DarkGray => (128, 128, 128),
75            Color::LightRed => (255, 0, 0),
76            Color::LightGreen => (0, 255, 0),
77            Color::LightYellow => (255, 255, 0),
78            Color::LightBlue => (0, 0, 255),
79            Color::LightMagenta => (255, 0, 255),
80            Color::LightCyan => (0, 255, 255),
81            Color::LightWhite => (255, 255, 255),
82            Color::Reset => (0, 0, 0),
83            Color::Indexed(idx) => xterm256_to_rgb(idx),
84        }
85    }
86
87    /// Compute relative luminance using ITU-R BT.709 coefficients.
88    ///
89    /// Returns a value in `[0.0, 1.0]` where 0 is darkest and 1 is brightest.
90    /// Use this to determine whether text on a given background should be
91    /// light or dark.
92    ///
93    /// # Example
94    ///
95    /// ```
96    /// use slt::Color;
97    ///
98    /// let dark = Color::Rgb(30, 30, 46);
99    /// assert!(dark.luminance() < 0.15);
100    ///
101    /// let light = Color::Rgb(205, 214, 244);
102    /// assert!(light.luminance() > 0.6);
103    /// ```
104    pub fn luminance(self) -> f32 {
105        let (r, g, b) = self.to_rgb();
106        let rf = to_linear(r as f32 / 255.0);
107        let gf = to_linear(g as f32 / 255.0);
108        let bf = to_linear(b as f32 / 255.0);
109        0.2126 * rf + 0.7152 * gf + 0.0722 * bf
110    }
111
112    /// Return a contrasting foreground color for the given background.
113    ///
114    /// Uses the WCAG 2.1 relative luminance threshold (0.179) to decide
115    /// between white and black text. For theme-aware contrast, prefer using
116    /// this over hardcoding `theme.bg` as the foreground.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use slt::Color;
122    ///
123    /// let bg = Color::Rgb(189, 147, 249); // Dracula purple
124    /// let fg = Color::contrast_fg(bg);
125    /// // Dracula purple → white (WCAG luminance 0.385 < 0.179 threshold)
126    /// ```
127    pub fn contrast_fg(bg: Color) -> Color {
128        if bg.luminance() > 0.179 {
129            Color::Rgb(0, 0, 0)
130        } else {
131            Color::Rgb(255, 255, 255)
132        }
133    }
134
135    /// Blend this color over another with the given alpha.
136    ///
137    /// `alpha` is in `[0.0, 1.0]` where 0.0 returns `other` unchanged and
138    /// 1.0 returns `self` unchanged. Both colors are resolved to RGB.
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use slt::Color;
144    ///
145    /// let white = Color::Rgb(255, 255, 255);
146    /// let black = Color::Rgb(0, 0, 0);
147    /// let gray = white.blend(black, 0.5);
148    /// // ≈ Rgb(128, 128, 128)
149    /// ```
150    pub fn blend(self, other: Color, alpha: f32) -> Color {
151        let alpha = alpha.clamp(0.0, 1.0);
152        let (r1, g1, b1) = self.to_rgb();
153        let (r2, g2, b2) = other.to_rgb();
154        let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
155        let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
156        let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
157        Color::Rgb(r, g, b)
158    }
159
160    /// Lighten this color by the given amount (0.0–1.0).
161    ///
162    /// Blends toward white. `amount = 0.0` returns the original color;
163    /// `amount = 1.0` returns white.
164    pub fn lighten(self, amount: f32) -> Color {
165        Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
166    }
167
168    /// Darken this color by the given amount (0.0–1.0).
169    ///
170    /// Blends toward black. `amount = 0.0` returns the original color;
171    /// `amount = 1.0` returns black.
172    pub fn darken(self, amount: f32) -> Color {
173        Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
174    }
175
176    /// Compute the WCAG 2.1 contrast ratio between two colors.
177    ///
178    /// Returns a value >= 1.0. A ratio >= 4.5 meets WCAG AA for normal text;
179    /// >= 3.0 meets AA for large text.
180    ///
181    /// # Example
182    ///
183    /// ```
184    /// use slt::Color;
185    ///
186    /// let ratio = Color::contrast_ratio(Color::White, Color::Black);
187    /// assert!(ratio > 15.0);
188    /// ```
189    pub fn contrast_ratio(a: Color, b: Color) -> f32 {
190        let la = a.luminance() + 0.05;
191        let lb = b.luminance() + 0.05;
192        if la > lb {
193            la / lb
194        } else {
195            lb / la
196        }
197    }
198
199    /// Returns `true` if the contrast ratio between two colors meets WCAG AA
200    /// for normal text (ratio >= 4.5).
201    pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
202        Self::contrast_ratio(fg, bg) >= 4.5
203    }
204
205    /// Downsample this color to fit the given color depth.
206    ///
207    /// - `TrueColor`: returns self unchanged.
208    /// - `EightBit`: converts `Rgb` to the nearest `Indexed` color.
209    /// - `Basic`: converts `Rgb` and `Indexed` to the nearest named color.
210    /// - `NoColor`: returns [`Color::Reset`] — emit no ANSI color at all.
211    ///
212    /// Named colors (`Red`, `Green`, etc.) and `Reset` pass through at
213    /// depths other than `NoColor`.
214    pub fn downsampled(self, depth: ColorDepth) -> Color {
215        match depth {
216            ColorDepth::TrueColor => self,
217            ColorDepth::EightBit => match self {
218                Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
219                other => other,
220            },
221            ColorDepth::Basic => match self {
222                Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
223                Color::Indexed(i) => {
224                    let (r, g, b) = xterm256_to_rgb(i);
225                    rgb_to_ansi16(r, g, b)
226                }
227                other => other,
228            },
229            ColorDepth::NoColor => Color::Reset,
230        }
231    }
232
233    /// Parse a hex string (`#rgb` or `#rrggbb`) into [`Color::Rgb`].
234    ///
235    /// The leading `#` is required. Short form `#rgb` expands each nibble
236    /// (`#abc` → `Rgb(0xaa, 0xbb, 0xcc)`). Returns `None` for any malformed
237    /// input (wrong length, non-hex digits, missing `#`).
238    ///
239    /// # Example
240    ///
241    /// ```
242    /// use slt::Color;
243    ///
244    /// assert_eq!(Color::from_hex("#ff6b6b"), Some(Color::Rgb(255, 107, 107)));
245    /// assert_eq!(Color::from_hex("#abc"), Some(Color::Rgb(170, 187, 204)));
246    /// assert_eq!(Color::from_hex("ff6b6b"), None); // missing '#'
247    /// assert_eq!(Color::from_hex("#xyz"), None); // non-hex
248    /// ```
249    #[doc(alias = "parse")]
250    pub fn from_hex(s: &str) -> Option<Color> {
251        let hex = s.strip_prefix('#')?;
252        match hex.len() {
253            3 => {
254                let mut it = hex.chars().map(|c| c.to_digit(16));
255                let r = it.next()??;
256                let g = it.next()??;
257                let b = it.next()??;
258                // Expand each nibble: 0xa -> 0xaa.
259                Some(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
260            }
261            6 => {
262                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
263                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
264                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
265                Some(Color::Rgb(r, g, b))
266            }
267            _ => None,
268        }
269    }
270
271    /// Format an `Rgb` color as a `#rrggbb` hex string.
272    ///
273    /// Non-`Rgb` variants are first resolved to their RGB equivalent via the
274    /// internal palette, so the result is always a valid `#rrggbb` token.
275    ///
276    /// # Example
277    ///
278    /// ```
279    /// use slt::Color;
280    ///
281    /// assert_eq!(Color::Rgb(255, 107, 107).to_hex(), "#ff6b6b");
282    /// ```
283    pub fn to_hex(self) -> String {
284        let (r, g, b) = self.to_rgb();
285        format!("#{r:02x}{g:02x}{b:02x}")
286    }
287
288    /// Construct an [`Color::Rgb`] from HSL components.
289    ///
290    /// `h` is the hue in degrees (wrapped into `0..360`), `s` is the
291    /// saturation and `l` the lightness, both clamped to `[0.0, 1.0]`.
292    ///
293    /// # Example
294    ///
295    /// ```
296    /// use slt::Color;
297    ///
298    /// assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
299    /// assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
300    /// assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
301    /// ```
302    pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
303        let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
304        Color::Rgb(r, g, b)
305    }
306
307    /// Construct an [`Color::Rgb`] from HSV (a.k.a. HSB) components.
308    ///
309    /// `h` is the hue in degrees (wrapped into `0..360`), `s` is the
310    /// saturation and `v` the value/brightness, both clamped to `[0.0, 1.0]`.
311    ///
312    /// # Example
313    ///
314    /// ```
315    /// use slt::Color;
316    ///
317    /// assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
318    /// assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
319    /// assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
320    /// ```
321    pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
322        let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
323        Color::Rgb(r, g, b)
324    }
325
326    /// Rotate the hue of this color by `degrees` around the HSL color wheel.
327    ///
328    /// The color is resolved to RGB, converted to HSL, rotated, and converted
329    /// back to [`Color::Rgb`]. Positive values rotate forward (red → green →
330    /// blue); negative values rotate backward. The result is always an
331    /// `Rgb` color regardless of the input variant — named and indexed colors
332    /// are first resolved via the internal palette.
333    ///
334    /// # Example
335    ///
336    /// ```
337    /// use slt::Color;
338    ///
339    /// // Rotating pure red by 120° lands on pure green.
340    /// assert_eq!(Color::Rgb(255, 0, 0).rotate_hue(120.0), Color::Rgb(0, 255, 0));
341    /// ```
342    pub fn rotate_hue(self, degrees: f32) -> Color {
343        let (r, g, b) = self.to_rgb();
344        let (h, s, l) = rgb_to_hsl(r, g, b);
345        let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
346        Color::Rgb(nr, ng, nb)
347    }
348}
349
350impl From<(u8, u8, u8)> for Color {
351    /// Construct an [`Color::Rgb`] from an `(r, g, b)` tuple.
352    fn from((r, g, b): (u8, u8, u8)) -> Color {
353        Color::Rgb(r, g, b)
354    }
355}
356
357impl From<[u8; 3]> for Color {
358    /// Construct an [`Color::Rgb`] from an `[r, g, b]` array.
359    fn from([r, g, b]: [u8; 3]) -> Color {
360        Color::Rgb(r, g, b)
361    }
362}
363
364impl From<u32> for Color {
365    /// Construct an [`Color::Rgb`] from a packed `0xRRGGBB` integer.
366    ///
367    /// The high byte (alpha / `0xAA______`) is ignored.
368    ///
369    /// # Example
370    ///
371    /// ```
372    /// use slt::Color;
373    ///
374    /// assert_eq!(Color::from(0xff6b6b), Color::Rgb(255, 107, 107));
375    /// ```
376    fn from(value: u32) -> Color {
377        let r = ((value >> 16) & 0xff) as u8;
378        let g = ((value >> 8) & 0xff) as u8;
379        let b = (value & 0xff) as u8;
380        Color::Rgb(r, g, b)
381    }
382}
383
384/// Error returned when [`Color`] fails to parse from a string.
385///
386/// Produced by the [`std::str::FromStr`] implementation for [`Color`].
387///
388/// # Example
389///
390/// ```
391/// use slt::Color;
392///
393/// let err = "#zz0011".parse::<Color>().unwrap_err();
394/// // Display renders a human-readable reason.
395/// assert!(err.to_string().contains("non-hex digit"));
396/// ```
397#[non_exhaustive]
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub enum ColorParseError {
400    /// The input had a hex form (`#…` or all hex-looking) but the wrong
401    /// number of digits (only 3 or 6 are accepted).
402    InvalidLength,
403    /// The input contained a character that is not a valid hex digit.
404    InvalidHexDigit,
405    /// The input did not match any known hex form or named color.
406    Unknown,
407}
408
409impl std::fmt::Display for ColorParseError {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        let msg = match self {
412            ColorParseError::InvalidLength => "invalid color: hex form must have 3 or 6 digits",
413            ColorParseError::InvalidHexDigit => "invalid color: non-hex digit in hex form",
414            ColorParseError::Unknown => {
415                "invalid color: expected #rgb/#rrggbb, rrggbb, or a named color"
416            }
417        };
418        f.write_str(msg)
419    }
420}
421
422impl std::error::Error for ColorParseError {}
423
424impl std::str::FromStr for Color {
425    type Err = ColorParseError;
426
427    /// Parse a color from a string.
428    ///
429    /// Accepts hex (`#rgb`, `#rrggbb`, or bare `rrggbb` / `rgb` without the
430    /// leading `#`) and case-insensitive named colors (`"red"`, `"lightblue"`,
431    /// `"darkgray"`, `"reset"`, …).
432    ///
433    /// # Errors
434    ///
435    /// Returns [`ColorParseError`] when the input matches no known form:
436    /// [`ColorParseError::InvalidLength`] for a hex token of the wrong
437    /// length, [`ColorParseError::InvalidHexDigit`] for non-hex digits in a
438    /// `#`-prefixed token, and [`ColorParseError::Unknown`] otherwise.
439    ///
440    /// # Example
441    ///
442    /// ```
443    /// use slt::Color;
444    ///
445    /// assert_eq!("#ff6b6b".parse::<Color>(), Ok(Color::Rgb(255, 107, 107)));
446    /// assert_eq!("ff6b6b".parse::<Color>(), Ok(Color::Rgb(255, 107, 107)));
447    /// assert_eq!("#abc".parse::<Color>(), Ok(Color::Rgb(170, 187, 204)));
448    /// assert_eq!("cyan".parse::<Color>(), Ok(Color::Cyan));
449    /// assert!("nope".parse::<Color>().is_err());
450    /// ```
451    fn from_str(s: &str) -> Result<Color, ColorParseError> {
452        let trimmed = s.trim();
453
454        // Named colors take priority over the no-`#` hex path so that a name
455        // like "red" is never mistaken for a hex token.
456        if let Some(c) = named_color(trimmed) {
457            return Ok(c);
458        }
459
460        let had_hash = trimmed.starts_with('#');
461        let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
462
463        match hex.len() {
464            3 => {
465                let mut it = hex.chars().map(|c| c.to_digit(16));
466                let r = it
467                    .next()
468                    .flatten()
469                    .ok_or(ColorParseError::InvalidHexDigit)?;
470                let g = it
471                    .next()
472                    .flatten()
473                    .ok_or(ColorParseError::InvalidHexDigit)?;
474                let b = it
475                    .next()
476                    .flatten()
477                    .ok_or(ColorParseError::InvalidHexDigit)?;
478                Ok(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
479            }
480            6 => {
481                let r = u8::from_str_radix(&hex[0..2], 16)
482                    .map_err(|_| ColorParseError::InvalidHexDigit)?;
483                let g = u8::from_str_radix(&hex[2..4], 16)
484                    .map_err(|_| ColorParseError::InvalidHexDigit)?;
485                let b = u8::from_str_radix(&hex[4..6], 16)
486                    .map_err(|_| ColorParseError::InvalidHexDigit)?;
487                Ok(Color::Rgb(r, g, b))
488            }
489            // A `#`-prefixed token that isn't 3 or 6 digits is clearly a
490            // malformed hex token; an unprefixed token of an odd length is
491            // simply an unknown name.
492            _ if had_hash => Err(ColorParseError::InvalidLength),
493            _ => Err(ColorParseError::Unknown),
494        }
495    }
496}
497
498/// Resolve a case-insensitive named color token (no `#`, no `indexed:`).
499///
500/// Returns `None` for anything that is not one of the 16 standard names plus
501/// the common aliases (`grey`, `default`).
502fn named_color(s: &str) -> Option<Color> {
503    let lower = s.to_ascii_lowercase();
504    Some(match lower.as_str() {
505        "reset" | "default" => Color::Reset,
506        "black" => Color::Black,
507        "red" => Color::Red,
508        "green" => Color::Green,
509        "yellow" => Color::Yellow,
510        "blue" => Color::Blue,
511        "magenta" => Color::Magenta,
512        "cyan" => Color::Cyan,
513        "white" => Color::White,
514        "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
515        "lightred" => Color::LightRed,
516        "lightgreen" => Color::LightGreen,
517        "lightyellow" => Color::LightYellow,
518        "lightblue" => Color::LightBlue,
519        "lightmagenta" => Color::LightMagenta,
520        "lightcyan" => Color::LightCyan,
521        "lightwhite" => Color::LightWhite,
522        _ => return None,
523    })
524}
525
526/// Convert HSL (`h` in degrees, `s`/`l` in `[0.0, 1.0]`) to `(r, g, b)`.
527///
528/// The hue is wrapped into `0..360`. Inputs are assumed already clamped by
529/// the caller.
530fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
531    let h = wrap_hue(h);
532    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
533    let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
534    let m = l - c / 2.0;
535    let (r1, g1, b1) = hue_sextant(h, c, x);
536    (
537        round_channel(r1 + m),
538        round_channel(g1 + m),
539        round_channel(b1 + m),
540    )
541}
542
543/// Convert HSV (`h` in degrees, `s`/`v` in `[0.0, 1.0]`) to `(r, g, b)`.
544///
545/// The hue is wrapped into `0..360`. Inputs are assumed already clamped by
546/// the caller.
547fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
548    let h = wrap_hue(h);
549    let c = v * s;
550    let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
551    let m = v - c;
552    let (r1, g1, b1) = hue_sextant(h, c, x);
553    (
554        round_channel(r1 + m),
555        round_channel(g1 + m),
556        round_channel(b1 + m),
557    )
558}
559
560/// Convert `(r, g, b)` to HSL with `h` in degrees `[0, 360)` and `s`/`l` in
561/// `[0.0, 1.0]`.
562fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
563    let rf = r as f32 / 255.0;
564    let gf = g as f32 / 255.0;
565    let bf = b as f32 / 255.0;
566    let max = rf.max(gf).max(bf);
567    let min = rf.min(gf).min(bf);
568    let delta = max - min;
569    let l = (max + min) / 2.0;
570
571    if delta <= f32::EPSILON {
572        // Achromatic: hue is undefined, conventionally 0.
573        return (0.0, 0.0, l);
574    }
575
576    let s = if l > 0.5 {
577        delta / (2.0 - max - min)
578    } else {
579        delta / (max + min)
580    };
581
582    let h = if max == rf {
583        let h = (gf - bf) / delta;
584        h % 6.0
585    } else if max == gf {
586        (bf - rf) / delta + 2.0
587    } else {
588        (rf - gf) / delta + 4.0
589    } * 60.0;
590
591    (wrap_hue(h), s, l)
592}
593
594/// Map a hue (already wrapped into `0..360`) and chroma components onto the
595/// six RGB sextants, returning the un-offset `(r, g, b)` floats.
596#[inline]
597fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
598    match h {
599        h if h < 60.0 => (c, x, 0.0),
600        h if h < 120.0 => (x, c, 0.0),
601        h if h < 180.0 => (0.0, c, x),
602        h if h < 240.0 => (0.0, x, c),
603        h if h < 300.0 => (x, 0.0, c),
604        _ => (c, 0.0, x),
605    }
606}
607
608/// Wrap a hue in degrees into the half-open range `[0.0, 360.0)`.
609#[inline]
610fn wrap_hue(h: f32) -> f32 {
611    let h = h % 360.0;
612    if h < 0.0 {
613        h + 360.0
614    } else {
615        h
616    }
617}
618
619/// Scale a `[0.0, 1.0]` channel to a rounded, clamped `u8`.
620#[inline]
621fn round_channel(v: f32) -> u8 {
622    (v * 255.0).round().clamp(0.0, 255.0) as u8
623}
624
625#[cfg(feature = "serde")]
626impl Color {
627    /// Serialized token for a named color, or `None` for non-named variants.
628    fn named_token(self) -> Option<&'static str> {
629        Some(match self {
630            Color::Reset => "reset",
631            Color::Black => "black",
632            Color::Red => "red",
633            Color::Green => "green",
634            Color::Yellow => "yellow",
635            Color::Blue => "blue",
636            Color::Magenta => "magenta",
637            Color::Cyan => "cyan",
638            Color::White => "white",
639            Color::DarkGray => "darkgray",
640            Color::LightRed => "lightred",
641            Color::LightGreen => "lightgreen",
642            Color::LightYellow => "lightyellow",
643            Color::LightBlue => "lightblue",
644            Color::LightMagenta => "lightmagenta",
645            Color::LightCyan => "lightcyan",
646            Color::LightWhite => "lightwhite",
647            Color::Rgb(..) | Color::Indexed(_) => return None,
648        })
649    }
650
651    /// Parse a color from a human-friendly token used in theme files.
652    ///
653    /// Accepts `#rgb` / `#rrggbb` hex, named colors (case-insensitive, e.g.
654    /// `"cyan"`, `"lightblue"`, `"darkgray"`, `"reset"`), and `indexed:N`
655    /// palette indices (`0..=255`).
656    fn from_token(s: &str) -> Option<Color> {
657        if let Some(c) = Color::from_hex(s) {
658            return Some(c);
659        }
660        let lower = s.trim().to_ascii_lowercase();
661        if let Some(rest) = lower.strip_prefix("indexed:") {
662            return rest.trim().parse::<u8>().ok().map(Color::Indexed);
663        }
664        Some(match lower.as_str() {
665            "reset" | "default" => Color::Reset,
666            "black" => Color::Black,
667            "red" => Color::Red,
668            "green" => Color::Green,
669            "yellow" => Color::Yellow,
670            "blue" => Color::Blue,
671            "magenta" => Color::Magenta,
672            "cyan" => Color::Cyan,
673            "white" => Color::White,
674            "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
675            "lightred" => Color::LightRed,
676            "lightgreen" => Color::LightGreen,
677            "lightyellow" => Color::LightYellow,
678            "lightblue" => Color::LightBlue,
679            "lightmagenta" => Color::LightMagenta,
680            "lightcyan" => Color::LightCyan,
681            "lightwhite" => Color::LightWhite,
682            _ => return None,
683        })
684    }
685
686    /// The canonical serialized token for this color.
687    ///
688    /// Named colors emit their lowercase name, `Rgb` emits `#rrggbb`,
689    /// `Indexed(n)` emits `indexed:n`. This is the inverse of [`Color::from_token`].
690    fn to_token(self) -> String {
691        if let Some(name) = self.named_token() {
692            return name.to_string();
693        }
694        match self {
695            Color::Indexed(n) => format!("indexed:{n}"),
696            // `Rgb` and any other true-color variant.
697            other => other.to_hex(),
698        }
699    }
700}
701
702#[cfg(feature = "serde")]
703impl serde::Serialize for Color {
704    /// Serialize as a human-friendly string token (`#rrggbb`, a named color,
705    /// or `indexed:N`) so theme files stay hand-editable and round-trip.
706    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
707    where
708        S: serde::Serializer,
709    {
710        serializer.serialize_str(&self.to_token())
711    }
712}
713
714#[cfg(feature = "serde")]
715impl<'de> serde::Deserialize<'de> for Color {
716    /// Deserialize from a token string: `#rgb`/`#rrggbb`, a named color
717    /// (case-insensitive), or `indexed:N`.
718    fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
719    where
720        D: serde::Deserializer<'de>,
721    {
722        struct ColorVisitor;
723
724        impl serde::de::Visitor<'_> for ColorVisitor {
725            type Value = Color;
726
727            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
728                f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
729            }
730
731            fn visit_str<E>(self, value: &str) -> Result<Color, E>
732            where
733                E: serde::de::Error,
734            {
735                Color::from_token(value).ok_or_else(|| {
736                    E::custom(format!(
737                        "invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
738                    ))
739                })
740            }
741        }
742
743        deserializer.deserialize_str(ColorVisitor)
744    }
745}
746
747/// Terminal color depth capability.
748///
749/// Determines the maximum number of colors a terminal can display.
750/// Use [`ColorDepth::detect`] for automatic detection via environment
751/// variables, or specify explicitly in [`crate::RunConfig`].
752#[non_exhaustive]
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
754#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
755pub enum ColorDepth {
756    /// 24-bit true color (16 million colors).
757    TrueColor,
758    /// 256-color palette (xterm-256color).
759    EightBit,
760    /// 16 basic ANSI colors.
761    Basic,
762    /// No color output — every color is downsampled to [`Color::Reset`] and
763    /// the terminal emits no SGR color codes. Selected automatically by
764    /// [`ColorDepth::detect`] when the `NO_COLOR` environment variable is
765    /// set to any non-empty value, per <https://no-color.org>.
766    NoColor,
767}
768
769#[cfg(test)]
770mod color_depth_tests {
771    use super::{Color, ColorDepth};
772
773    #[test]
774    fn no_color_downsamples_everything_to_reset() {
775        assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
776        assert_eq!(
777            Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
778            Color::Reset
779        );
780        assert_eq!(
781            Color::Indexed(44).downsampled(ColorDepth::NoColor),
782            Color::Reset
783        );
784    }
785}
786
787impl ColorDepth {
788    /// Detect the terminal's color depth from environment variables.
789    ///
790    /// Order of precedence:
791    /// 1. `NO_COLOR` (any non-empty value) → [`ColorDepth::NoColor`]
792    /// 2. `COLORTERM=truecolor|24bit` → [`ColorDepth::TrueColor`]
793    /// 3. `TERM` contains `256color` → [`ColorDepth::EightBit`]
794    /// 4. Fallback → [`ColorDepth::Basic`] (16 colors)
795    pub fn detect() -> Self {
796        // https://no-color.org — ANY non-empty value disables color.
797        if std::env::var("NO_COLOR")
798            .ok()
799            .is_some_and(|v| !v.is_empty())
800        {
801            return Self::NoColor;
802        }
803        if let Ok(ct) = std::env::var("COLORTERM") {
804            let ct = ct.to_lowercase();
805            if ct == "truecolor" || ct == "24bit" {
806                return Self::TrueColor;
807            }
808        }
809        if let Ok(term) = std::env::var("TERM") {
810            if term.contains("256color") {
811                return Self::EightBit;
812            }
813        }
814        Self::Basic
815    }
816}
817
818fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
819    if r == g && g == b {
820        if r < 8 {
821            return 16;
822        }
823        if r >= 248 {
824            return 231;
825        }
826        return 232 + (((r as u16 - 8) * 24 / 240) as u8);
827    }
828
829    let ri = if r < 48 {
830        0
831    } else {
832        ((r as u16 - 35) / 40) as u8
833    };
834    let gi = if g < 48 {
835        0
836    } else {
837        ((g as u16 - 35) / 40) as u8
838    };
839    let bi = if b < 48 {
840        0
841    } else {
842        ((b as u16 - 35) / 40) as u8
843    };
844    16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
845}
846
847fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
848    let lum = 0.2126 * to_linear(r as f32 / 255.0)
849        + 0.7152 * to_linear(g as f32 / 255.0)
850        + 0.0722 * to_linear(b as f32 / 255.0);
851
852    let max = r.max(g).max(b);
853    let min = r.min(g).min(b);
854    let saturation = if max == 0 {
855        0.0
856    } else {
857        (max - min) as f32 / max as f32
858    };
859
860    if saturation < 0.2 {
861        // Grayscale: classify purely by luminance.
862        return match lum {
863            l if l < 0.05 => Color::Black,
864            l if l < 0.25 => Color::DarkGray,
865            l if l < 0.7 => Color::White,
866            _ => Color::White, // LightWhite is not available in Color enum
867        };
868    }
869
870    // For chromatic colors the "bright" variant of each ANSI hue (e.g. xterm
871    // LightRed = `(255, 85, 85)`) is distinguished by the minimum channel
872    // being lifted off zero — it's a brighter, partially desaturated version
873    // of the same hue. A perfectly saturated primary (`min == 0`) like pure
874    // red `(255, 0, 0)` should map to the standard color. We pick the bright
875    // variant only when both the value is high and the color is desaturated
876    // enough to look "lifted".
877    let bright = max >= 200 && min >= 64;
878
879    let rf = r as f32;
880    let gf = g as f32;
881    let bf = b as f32;
882
883    if rf >= gf && rf >= bf {
884        if gf > bf * 1.5 {
885            if bright {
886                Color::LightYellow
887            } else {
888                Color::Yellow
889            }
890        } else if bf > gf * 1.5 {
891            if bright {
892                Color::LightMagenta
893            } else {
894                Color::Magenta
895            }
896        } else if bright {
897            Color::LightRed
898        } else {
899            Color::Red
900        }
901    } else if gf >= rf && gf >= bf {
902        if bf > rf * 1.5 {
903            if bright {
904                Color::LightCyan
905            } else {
906                Color::Cyan
907            }
908        } else if bright {
909            Color::LightGreen
910        } else {
911            Color::Green
912        }
913    } else if rf > gf * 1.5 {
914        if bright {
915            Color::LightMagenta
916        } else {
917            Color::Magenta
918        }
919    } else if gf > rf * 1.5 {
920        if bright {
921            Color::LightCyan
922        } else {
923            Color::Cyan
924        }
925    } else if bright {
926        Color::LightBlue
927    } else {
928        Color::Blue
929    }
930}
931
932fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
933    match idx {
934        0 => (0, 0, 0),
935        1 => (128, 0, 0),
936        2 => (0, 128, 0),
937        3 => (128, 128, 0),
938        4 => (0, 0, 128),
939        5 => (128, 0, 128),
940        6 => (0, 128, 128),
941        7 => (192, 192, 192),
942        8 => (128, 128, 128),
943        9 => (255, 0, 0),
944        10 => (0, 255, 0),
945        11 => (255, 255, 0),
946        12 => (0, 0, 255),
947        13 => (255, 0, 255),
948        14 => (0, 255, 255),
949        15 => (255, 255, 255),
950        16..=231 => {
951            let n = idx - 16;
952            let b_idx = n % 6;
953            let g_idx = (n / 6) % 6;
954            let r_idx = n / 36;
955            let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
956            (to_val(r_idx), to_val(g_idx), to_val(b_idx))
957        }
958        232..=255 => {
959            let v = 8 + 10 * (idx - 232);
960            (v, v, v)
961        }
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    #![allow(clippy::unwrap_used)]
968    use super::*;
969
970    #[test]
971    fn blend_halfway_rounds_to_128() {
972        assert_eq!(
973            Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
974            Color::Rgb(128, 128, 128)
975        );
976    }
977
978    #[test]
979    fn contrast_ratio_white_on_black_is_high() {
980        let ratio = Color::contrast_ratio(Color::White, Color::Black);
981        assert!(ratio > 15.0);
982    }
983
984    #[test]
985    fn contrast_ratio_same_color_is_one() {
986        let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
987        assert!((ratio - 1.0).abs() < 0.01);
988    }
989
990    #[test]
991    fn meets_contrast_aa_white_on_black() {
992        assert!(Color::meets_contrast_aa(Color::White, Color::Black));
993    }
994
995    #[test]
996    fn meets_contrast_aa_low_contrast_fails() {
997        assert!(!Color::meets_contrast_aa(
998            Color::Rgb(180, 180, 180),
999            Color::Rgb(200, 200, 200)
1000        ));
1001    }
1002
1003    // --- regression: issue #104 rgb_to_ansi256 overflow at r=g=b=248 ---
1004
1005    #[test]
1006    fn rgb_to_ansi256_no_overflow_full_range() {
1007        // 256^3 exhaustive — guarantees no panic in debug or release builds
1008        for r in 0u8..=255 {
1009            for g in 0u8..=255 {
1010                for b in 0u8..=255 {
1011                    let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
1012                }
1013            }
1014        }
1015    }
1016
1017    #[test]
1018    fn rgb_248_maps_to_231() {
1019        assert_eq!(
1020            Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
1021            Color::Indexed(231)
1022        );
1023    }
1024
1025    // --- regression: issue #105 WCAG luminance sRGB gamma ---
1026
1027    #[test]
1028    fn luminance_dracula_purple_wcag() {
1029        let l = Color::Rgb(189, 147, 249).luminance();
1030        assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
1031    }
1032
1033    #[test]
1034    fn contrast_aa_dracula_pair() {
1035        let p = Color::Rgb(189, 147, 249);
1036        let bg = Color::Rgb(40, 42, 54);
1037        assert!(Color::meets_contrast_aa(p, bg));
1038        let r = Color::contrast_ratio(p, bg);
1039        assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
1040    }
1041
1042    #[test]
1043    fn contrast_white_on_black_is_21() {
1044        let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
1045        assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
1046    }
1047
1048    // --- regression: issue #107 rgb_to_ansi16 includes bright (8-15) colors ---
1049
1050    #[test]
1051    fn rgb_to_ansi16_bright_variants() {
1052        // bright red → LightRed
1053        assert_eq!(
1054            Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
1055            Color::LightRed
1056        );
1057        // dark red → Red
1058        assert_eq!(
1059            Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
1060            Color::Red
1061        );
1062        // bright gray → White
1063        assert_eq!(
1064            Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
1065            Color::White
1066        );
1067        // dark gray → DarkGray
1068        assert_eq!(
1069            Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
1070            Color::DarkGray
1071        );
1072    }
1073
1074    // --- v0.21.1: ergonomic constructors / conversions ---
1075
1076    use std::str::FromStr;
1077
1078    #[test]
1079    fn from_tuple_and_array() {
1080        assert_eq!(Color::from((255, 107, 107)), Color::Rgb(255, 107, 107));
1081        assert_eq!(Color::from([1u8, 2, 3]), Color::Rgb(1, 2, 3));
1082        // Generic `.into()` path resolves through the same impls.
1083        let c: Color = (10, 20, 30).into();
1084        assert_eq!(c, Color::Rgb(10, 20, 30));
1085    }
1086
1087    #[test]
1088    fn from_u32_packs_rrggbb() {
1089        assert_eq!(Color::from(0xff6b6b_u32), Color::Rgb(255, 107, 107));
1090        assert_eq!(Color::from(0x000000_u32), Color::Rgb(0, 0, 0));
1091        assert_eq!(Color::from(0xffffff_u32), Color::Rgb(255, 255, 255));
1092        // High byte (alpha) is ignored.
1093        assert_eq!(Color::from(0xff00ff00_u32), Color::Rgb(0, 255, 0));
1094    }
1095
1096    #[test]
1097    fn from_str_hex_round_trips() {
1098        assert_eq!(
1099            Color::from_str("#ff6b6b").unwrap(),
1100            Color::Rgb(255, 107, 107)
1101        );
1102        // No leading '#'.
1103        assert_eq!(
1104            Color::from_str("ff6b6b").unwrap(),
1105            Color::Rgb(255, 107, 107)
1106        );
1107        // Short form expands nibbles.
1108        assert_eq!(Color::from_str("#abc").unwrap(), Color::Rgb(170, 187, 204));
1109        assert_eq!(Color::from_str("abc").unwrap(), Color::Rgb(170, 187, 204));
1110        // Whitespace is trimmed.
1111        assert_eq!(
1112            Color::from_str("  #ff6b6b  ").unwrap(),
1113            Color::Rgb(255, 107, 107)
1114        );
1115        // Hex parse matches to_hex round-trip.
1116        let c = Color::Rgb(18, 52, 86);
1117        assert_eq!(Color::from_str(&c.to_hex()).unwrap(), c);
1118    }
1119
1120    #[test]
1121    fn from_str_named_colors() {
1122        assert_eq!(Color::from_str("cyan").unwrap(), Color::Cyan);
1123        assert_eq!(Color::from_str("LightBlue").unwrap(), Color::LightBlue);
1124        assert_eq!(Color::from_str("DARKGRAY").unwrap(), Color::DarkGray);
1125        assert_eq!(Color::from_str("grey").unwrap(), Color::DarkGray);
1126        assert_eq!(Color::from_str("reset").unwrap(), Color::Reset);
1127        assert_eq!(Color::from_str("default").unwrap(), Color::Reset);
1128    }
1129
1130    #[test]
1131    fn from_str_error_cases() {
1132        // Wrong length with '#' → InvalidLength.
1133        assert_eq!(
1134            Color::from_str("#ff6b").unwrap_err(),
1135            ColorParseError::InvalidLength
1136        );
1137        // Non-hex digit in a '#'-prefixed 6-char token → InvalidHexDigit.
1138        assert_eq!(
1139            Color::from_str("#zz0011").unwrap_err(),
1140            ColorParseError::InvalidHexDigit
1141        );
1142        // Non-hex digit in a 3-char token → InvalidHexDigit.
1143        assert_eq!(
1144            Color::from_str("#xyz").unwrap_err(),
1145            ColorParseError::InvalidHexDigit
1146        );
1147        // Unknown name of non-hex length → Unknown.
1148        assert_eq!(
1149            Color::from_str("nope").unwrap_err(),
1150            ColorParseError::Unknown
1151        );
1152        assert_eq!(Color::from_str("").unwrap_err(), ColorParseError::Unknown);
1153    }
1154
1155    #[test]
1156    fn color_parse_error_display_and_error_trait() {
1157        // Display is non-empty and Error trait is implemented.
1158        let e = ColorParseError::InvalidLength;
1159        assert!(!e.to_string().is_empty());
1160        let _: &dyn std::error::Error = &e;
1161    }
1162
1163    #[test]
1164    fn from_hsl_primaries() {
1165        assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1166        assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
1167        assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1168        // Lightness extremes.
1169        assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
1170        assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255));
1171        // Zero saturation → gray regardless of hue.
1172        assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128));
1173    }
1174
1175    #[test]
1176    fn from_hsl_wraps_and_clamps() {
1177        // Hue 360 wraps to 0 → red.
1178        assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1179        // Negative hue wraps: -120 == 240 → blue.
1180        assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1181        // Out-of-range s/l are clamped, no panic.
1182        assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255));
1183    }
1184
1185    #[test]
1186    fn from_hsv_primaries() {
1187        assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
1188        assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
1189        assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
1190        // White and black.
1191        assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
1192        assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
1193    }
1194
1195    #[test]
1196    fn rotate_hue_primary_round_trip() {
1197        // Red rotated 120° → green, another 120° → blue.
1198        assert_eq!(
1199            Color::Rgb(255, 0, 0).rotate_hue(120.0),
1200            Color::Rgb(0, 255, 0)
1201        );
1202        assert_eq!(
1203            Color::Rgb(0, 255, 0).rotate_hue(120.0),
1204            Color::Rgb(0, 0, 255)
1205        );
1206        // 180° on red lands on cyan.
1207        assert_eq!(
1208            Color::Rgb(255, 0, 0).rotate_hue(180.0),
1209            Color::Rgb(0, 255, 255)
1210        );
1211        // Full 360° rotation is a no-op (within rounding) for a primary.
1212        assert_eq!(
1213            Color::Rgb(255, 0, 0).rotate_hue(360.0),
1214            Color::Rgb(255, 0, 0)
1215        );
1216    }
1217
1218    #[test]
1219    fn rotate_hue_resolves_named_to_rgb() {
1220        // Named/indexed colors resolve through the palette and yield Rgb.
1221        let rotated = Color::Red.rotate_hue(0.0);
1222        assert_eq!(rotated, Color::Rgb(205, 49, 49));
1223        let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0);
1224        // Achromatic input stays achromatic (gray) after rotation.
1225        assert_eq!(gray, Color::Rgb(120, 120, 120));
1226    }
1227}