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