Skip to main content

retroglyph_core/color/
convert.rs

1//! `Color`'s inherent methods: named constants, RGB resolution, and the `gem`-backed color-space
2//! conversions. See `named` for the string-name/hex constructors (`Color::from_named`,
3//! `Color::from_hex`).
4
5use gem::Mix as _;
6use gem::rgb::Rgb888;
7use gem::space::Srgb;
8
9use super::Color;
10use super::ansi::AnsiColor;
11use super::ansi::indexed_to_rgb;
12use super::ansi::rgb_to_srgb;
13use super::ansi::{Quantize, rgb_to_ansi, rgb_to_indexed};
14
15impl Color {
16    /// Standard Black (ANSI).
17    pub const BLACK: Self = Self::Ansi(AnsiColor::Black);
18    /// Standard Red (ANSI).
19    pub const RED: Self = Self::Ansi(AnsiColor::Red);
20    /// Standard Green (ANSI).
21    pub const GREEN: Self = Self::Ansi(AnsiColor::Green);
22    /// Standard Yellow (ANSI).
23    pub const YELLOW: Self = Self::Ansi(AnsiColor::Yellow);
24    /// Standard Blue (ANSI).
25    pub const BLUE: Self = Self::Ansi(AnsiColor::Blue);
26    /// Standard Magenta (ANSI).
27    pub const MAGENTA: Self = Self::Ansi(AnsiColor::Magenta);
28    /// Standard Cyan (ANSI).
29    pub const CYAN: Self = Self::Ansi(AnsiColor::Cyan);
30    /// Standard White (ANSI).
31    pub const WHITE: Self = Self::Ansi(AnsiColor::White);
32    /// Bright Black / dark grey (ANSI).
33    pub const BRIGHT_BLACK: Self = Self::Ansi(AnsiColor::BrightBlack);
34    /// Bright Red (ANSI).
35    pub const BRIGHT_RED: Self = Self::Ansi(AnsiColor::BrightRed);
36    /// Bright Green (ANSI).
37    pub const BRIGHT_GREEN: Self = Self::Ansi(AnsiColor::BrightGreen);
38    /// Bright Yellow (ANSI).
39    pub const BRIGHT_YELLOW: Self = Self::Ansi(AnsiColor::BrightYellow);
40    /// Bright Blue (ANSI).
41    pub const BRIGHT_BLUE: Self = Self::Ansi(AnsiColor::BrightBlue);
42    /// Bright Magenta (ANSI).
43    pub const BRIGHT_MAGENTA: Self = Self::Ansi(AnsiColor::BrightMagenta);
44    /// Bright Cyan (ANSI).
45    pub const BRIGHT_CYAN: Self = Self::Ansi(AnsiColor::BrightCyan);
46    /// Bright White (ANSI).
47    pub const BRIGHT_WHITE: Self = Self::Ansi(AnsiColor::BrightWhite);
48
49    /// Constructs an `Rgb` variant from its three channels.
50    #[must_use]
51    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
52        Self::Rgb { r, g, b }
53    }
54
55    /// Constructs an `Rgb` variant from a packed `0xRRGGBB` value. The high byte is ignored.
56    ///
57    /// Round-trips the `#rrggbb` form [`Display`](core::fmt::Display) writes, so a color logged
58    /// out of a running app can be pasted straight back into source. Unlike
59    /// [`from_hex`](Self::from_hex), there is no CSS short-form expansion: `Color::hex(0xF80)` is
60    /// `#000f80`, not `#ff8800`.
61    #[must_use]
62    pub const fn hex(rgb: u32) -> Self {
63        Self::Rgb {
64            r: ((rgb >> 16) & 0xFF) as u8,
65            g: ((rgb >> 8) & 0xFF) as u8,
66            b: (rgb & 0xFF) as u8,
67        }
68    }
69
70    /// Resolves this color to a concrete 24-bit `(r, g, b)` triple, substituting `default` for
71    /// [`Color::Default`](crate::color::Color::Default).
72    ///
73    /// This is the canonical color-to-RGB resolution every graphical backend shares, so that a
74    /// glyph drawn through the CPU rasterizer (`retroglyph-software`) and the GPU atlas
75    /// (`retroglyph-gl`) comes out the same pixel color:
76    ///
77    /// - [`Rgb`](Self::Rgb) passes through unchanged.
78    /// - [`Ansi`](Self::Ansi) resolves through [`AnsiColor::to_rgb`](super::AnsiColor::to_rgb) (the one canonical ANSI
79    ///   palette).
80    /// - [`Indexed`](Self::Indexed) resolves through the 256-color palette (16 ANSI + 6×6×6 cube
81    ///   + grayscale ramp).
82    /// - [`Default`](Self::Default) (and any future non-exhaustive variant this crate can't yet
83    ///   resolve) returns `default`, which the caller picks per channel (foreground vs
84    ///   background).
85    ///
86    /// Terminal backends do *not* use this: they emit ANSI/indexed colors as-is and let the
87    /// terminal apply the user's theme. It exists specifically for pixel/GPU backends that must
88    /// produce real RGB.
89    #[must_use]
90    pub const fn resolve_rgb(self, default: (u8, u8, u8)) -> (u8, u8, u8) {
91        match self {
92            Self::Rgb { r, g, b } => (r, g, b),
93            Self::Ansi(ansi) => ansi.to_rgb(),
94            Self::Indexed(index) => indexed_to_rgb(index),
95            // `Color::Default` plus any future `#[non_exhaustive]` variant this crate doesn't yet
96            // know how to resolve to RGB.
97            _ => default,
98        }
99    }
100
101    // ── gem integration ────────────────────────────────────────────────────
102
103    /// Converts an `Rgb` variant to `gem::space::Srgb`.
104    ///
105    /// Returns `None` for non-RGB variants (`Default`, `Ansi`, `Indexed`).
106    #[must_use]
107    pub fn to_srgb(self) -> Option<Srgb> {
108        match self {
109            Self::Rgb { r, g, b } => Some(rgb_to_srgb(r, g, b)),
110            _ => None,
111        }
112    }
113
114    /// Constructs an `Rgb` variant from a `gem::space::Srgb` color.
115    ///
116    /// Channels are clamped to `[0.0, 1.0]` and rounded to the nearest `u8` (ties away from
117    /// zero), via `gem::rgb::Rgb888`'s own `Srgb` conversion, the same round-to-nearest rule
118    /// every other integer channel operation in this crate follows (see
119    /// `tests/rounding_conformance.rs`).
120    #[must_use]
121    pub fn from_srgb(srgb: Srgb) -> Self {
122        let (r, g, b) = Rgb888::from(srgb).to_rgb();
123        Self::Rgb { r, g, b }
124    }
125
126    /// Linearly interpolates between two colors, always returning a concrete `Rgb` result.
127    ///
128    /// Both inputs are resolved to `(r, g, b)` via [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before blending, so
129    /// non-`Rgb` variants (`Ansi`, `Indexed`) contribute their real color rather than being
130    /// skipped. [`Color::Default`](crate::color::Color::Default) has no intrinsic RGB value, so it falls back to
131    /// `(0, 0, 0)` when it appears as `a` and `(255, 255, 255)` when it appears as `b`.
132    #[must_use]
133    pub fn lerp(a: Self, b: Self, t: f32) -> Self {
134        let (r1, g1, b1) = a.resolve_rgb((0, 0, 0));
135        let (r2, g2, b2) = b.resolve_rgb((255, 255, 255));
136        let a_srgb = rgb_to_srgb(r1, g1, b1);
137        let b_srgb = rgb_to_srgb(r2, g2, b2);
138        Self::from_srgb(a_srgb.mix(b_srgb, t))
139    }
140
141    /// Applies `f` to this color's HSL representation and converts the result back to `Rgb`.
142    ///
143    /// Shared by [`Color::lighten`](crate::color::Color::lighten), [`Color::darken`](crate::color::Color::darken), [`Color::saturate`](crate::color::Color::saturate),
144    /// [`Color::desaturate`](crate::color::Color::desaturate), and [`Color::complement`](crate::color::Color::complement), which differ only in which
145    /// `gem::space::Hsl` method `f` calls. Non-`Rgb` variants are resolved to `(r, g, b)` via
146    /// [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before the transform is applied, rather than being returned
147    /// unchanged. [`Color::Default`](crate::color::Color::Default) has no intrinsic RGB value, so it resolves to `(0, 0, 0)`.
148    fn map_hsl(self, f: impl FnOnce(gem::space::Hsl) -> gem::space::Hsl) -> Self {
149        let (r, g, b) = self.resolve_rgb((0, 0, 0));
150        let hsl = gem::space::Hsl::from(rgb_to_srgb(r, g, b));
151        Self::from_srgb(Srgb::from(f(hsl)))
152    }
153
154    /// Lightens a color by `amount` (0.0 = no change, 1.0 = white).
155    ///
156    /// Non-`Rgb` variants are resolved to `(r, g, b)` via [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before the
157    /// transform is applied, rather than being returned unchanged. [`Color::Default`](crate::color::Color::Default) has no
158    /// intrinsic RGB value, so it resolves to `(0, 0, 0)`.
159    #[must_use]
160    pub fn lighten(self, amount: f32) -> Self {
161        self.map_hsl(|hsl| hsl.lighten(amount))
162    }
163
164    /// Darkens a color by `amount` (0.0 = no change, 1.0 = black).
165    ///
166    /// Non-`Rgb` variants are resolved to `(r, g, b)` via [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before the
167    /// transform is applied, rather than being returned unchanged. [`Color::Default`](crate::color::Color::Default) has no
168    /// intrinsic RGB value, so it resolves to `(0, 0, 0)`.
169    #[must_use]
170    pub fn darken(self, amount: f32) -> Self {
171        self.map_hsl(|hsl| hsl.darken(amount))
172    }
173
174    /// Increases saturation of a color by `amount` (0.0–1.0).
175    ///
176    /// Non-`Rgb` variants are resolved to `(r, g, b)` via [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before the
177    /// transform is applied, rather than being returned unchanged. [`Color::Default`](crate::color::Color::Default) has no
178    /// intrinsic RGB value, so it resolves to `(0, 0, 0)`.
179    #[must_use]
180    pub fn saturate(self, amount: f32) -> Self {
181        self.map_hsl(|hsl| hsl.saturate(amount))
182    }
183
184    /// Decreases saturation of a color by `amount` (0.0–1.0).
185    ///
186    /// Non-`Rgb` variants are resolved to `(r, g, b)` via [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before the
187    /// transform is applied, rather than being returned unchanged. [`Color::Default`](crate::color::Color::Default) has no
188    /// intrinsic RGB value, so it resolves to `(0, 0, 0)`.
189    #[must_use]
190    pub fn desaturate(self, amount: f32) -> Self {
191        self.map_hsl(|hsl| hsl.desaturate(amount))
192    }
193
194    /// Returns the complementary color (hue shifted by 180 degrees).
195    ///
196    /// Non-`Rgb` variants are resolved to `(r, g, b)` via [`Color::resolve_rgb`](crate::color::Color::resolve_rgb) before the
197    /// transform is applied, rather than being returned unchanged. [`Color::Default`](crate::color::Color::Default) has no
198    /// intrinsic RGB value, so it resolves to `(0, 0, 0)`.
199    #[must_use]
200    pub fn complement(self) -> Self {
201        self.map_hsl(gem::space::Hsl::complement)
202    }
203
204    /// Quantizes an RGB color to the nearest entry in the standard 256-color palette, by
205    /// perceptual (Oklab) distance.
206    ///
207    /// Equivalent to [`to_indexed_with(Quantize::Perceptual)`](Self::to_indexed_with); see there
208    /// for the full contract and for the euclidean alternative.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use retroglyph_core::color::Color;
214    ///
215    /// let black = Color::rgb(0, 0, 0);
216    /// assert_eq!(black.to_indexed(), Color::Indexed(0));
217    ///
218    /// // Non-RGB colors pass through unchanged.
219    /// assert_eq!(Color::Default.to_indexed(), Color::Default);
220    /// ```
221    ///
222    /// Backends that render to terminals without full RGB support can call this method to
223    /// downgrade colors before emitting them; `retroglyph-core` never downgrades colors on
224    /// its own. See [`Color::to_ansi`](crate::color::Color::to_ansi) to quantize to the smaller 16-color ANSI palette.
225    #[must_use]
226    pub fn to_indexed(self) -> Self {
227        self.to_indexed_with(Quantize::Perceptual)
228    }
229
230    /// Quantizes an RGB color to the nearest entry in the standard 256-color palette, under
231    /// `metric`.
232    ///
233    /// - `Color::Rgb` inputs are converted to the nearest 256-color palette index (0–255),
234    ///   searching the 16 ANSI colors (0–15), the 6×6×6 color cube (16–231), and the grayscale
235    ///   ramp (232–255).
236    /// - `Color::Default`, `Color::Ansi`, and `Color::Indexed` are returned unchanged: this
237    ///   method only downgrades `Rgb` colors.
238    /// - Ties (multiple equidistant palette entries) are resolved by preferring the lower
239    ///   index.
240    ///
241    /// # Examples
242    ///
243    /// ```
244    /// use retroglyph_core::color::{Color, Quantize};
245    ///
246    /// let salmon = Color::rgb(250, 128, 114);
247    /// assert_eq!(salmon.to_indexed_with(Quantize::Perceptual), Color::Indexed(210));
248    /// assert_eq!(salmon.to_indexed_with(Quantize::Euclidean), Color::Indexed(209));
249    /// ```
250    #[must_use]
251    pub fn to_indexed_with(self, metric: Quantize) -> Self {
252        match self {
253            Self::Rgb { r, g, b } => Self::Indexed(rgb_to_indexed(r, g, b, metric)),
254            other => other,
255        }
256    }
257
258    /// Quantizes an RGB color to the nearest of the 16 standard ANSI palette colors, by
259    /// perceptual (Oklab) distance.
260    ///
261    /// Equivalent to [`to_ansi_with(Quantize::Perceptual)`](Self::to_ansi_with); see there for the
262    /// full contract and for the euclidean alternative.
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// use retroglyph_core::color::{AnsiColor, Color};
268    ///
269    /// let pure_red = Color::rgb(255, 0, 0);
270    /// assert_eq!(pure_red.to_ansi(), Color::Ansi(AnsiColor::BrightRed));
271    ///
272    /// // Non-RGB colors pass through unchanged.
273    /// assert_eq!(Color::Default.to_ansi(), Color::Default);
274    /// ```
275    ///
276    /// Use this method when rendering to terminals limited to 16 colors, or when a caller
277    /// otherwise needs to reduce color depth. See [`Color::to_indexed`](crate::color::Color::to_indexed) to quantize to the
278    /// larger 256-color palette instead.
279    #[must_use]
280    pub fn to_ansi(self) -> Self {
281        self.to_ansi_with(Quantize::Perceptual)
282    }
283
284    /// Quantizes an RGB color to the nearest of the 16 standard ANSI palette colors, under
285    /// `metric`.
286    ///
287    /// - `Color::Rgb` inputs are converted to the nearest of the 16 standard ANSI colors.
288    /// - `Color::Default`, `Color::Ansi`, and `Color::Indexed` are returned unchanged: this
289    ///   method only downgrades `Rgb` colors.
290    /// - Ties (multiple equidistant palette entries) are resolved by preferring the lower
291    ///   ANSI index.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// use retroglyph_core::color::{AnsiColor, Color, Quantize};
297    ///
298    /// // Euclidean RGB distance over-weights green, so this reddish brown lands on yellow.
299    /// let chocolate = Color::rgb(210, 105, 30);
300    /// assert_eq!(chocolate.to_ansi_with(Quantize::Perceptual), Color::Ansi(AnsiColor::BrightRed));
301    /// assert_eq!(chocolate.to_ansi_with(Quantize::Euclidean), Color::Ansi(AnsiColor::Yellow));
302    /// ```
303    #[must_use]
304    pub fn to_ansi_with(self, metric: Quantize) -> Self {
305        match self {
306            Self::Rgb { r, g, b } => Self::Ansi(rgb_to_ansi(r, g, b, metric)),
307            other => other,
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::super::ansi::ANSI_COLORS;
315    use super::*;
316
317    #[test]
318    fn test_color_defaults() {
319        assert_eq!(Color::default(), Color::Default);
320    }
321
322    #[test]
323    fn test_rgb_and_hex_agree() {
324        // The two constructors are for different inputs (tuned channels vs. a packed literal),
325        // but must always agree on the same color.
326        assert_eq!(Color::hex(0x00DC_5A5A), Color::rgb(220, 90, 90));
327        assert_eq!(Color::hex(0x0000_0000), Color::rgb(0, 0, 0));
328        assert_eq!(Color::hex(0x00FF_FFFF), Color::rgb(255, 255, 255));
329    }
330
331    #[test]
332    fn test_hex_ignores_the_high_byte_and_does_not_expand_short_form() {
333        // The high byte (bits 24..32) is ignored, not validated.
334        assert_eq!(Color::hex(0xFF00_0000), Color::rgb(0, 0, 0));
335        // Unlike `Color::from_hex("#f80")`, which expands `#f80` to `#ff8800`, `hex(0xF80)` is
336        // just the literal 24-bit value `0x000F80`: no CSS short-form expansion.
337        assert_eq!(Color::hex(0x0F80), Color::rgb(0x00, 0x0F, 0x80));
338    }
339
340    #[test]
341    fn test_resolve_rgb() {
342        // Rgb passes through; Default uses the supplied fallback (per channel).
343        assert_eq!(Color::rgb(10, 20, 30).resolve_rgb((1, 2, 3)), (10, 20, 30));
344        assert_eq!(Color::Default.resolve_rgb((1, 2, 3)), (1, 2, 3));
345        // Ansi resolves through the canonical palette.
346        assert_eq!(
347            Color::Ansi(AnsiColor::Red).resolve_rgb((0, 0, 0)),
348            AnsiColor::Red.to_rgb()
349        );
350        // Indexed: 0..16 == ANSI, the cube and grayscale ramp resolve too.
351        assert_eq!(
352            Color::Indexed(1).resolve_rgb((0, 0, 0)),
353            AnsiColor::Red.to_rgb()
354        );
355        assert_eq!(Color::Indexed(16).resolve_rgb((0, 0, 0)), (0, 0, 0));
356        assert_eq!(Color::Indexed(231).resolve_rgb((0, 0, 0)), (255, 255, 255));
357        assert_eq!(Color::Indexed(232).resolve_rgb((0, 0, 0)), (8, 8, 8));
358    }
359
360    // ── to_indexed / to_ansi (non-RGB passthrough) ─────────────────────────
361
362    #[test]
363    fn test_to_indexed_non_rgb_passthrough() {
364        assert_eq!(Color::Default.to_indexed(), Color::Default);
365        assert_eq!(
366            Color::Ansi(AnsiColor::Red).to_indexed(),
367            Color::Ansi(AnsiColor::Red)
368        );
369        assert_eq!(Color::Indexed(42).to_indexed(), Color::Indexed(42));
370    }
371
372    #[test]
373    fn test_to_ansi_non_rgb_passthrough() {
374        assert_eq!(Color::Default.to_ansi(), Color::Default);
375        assert_eq!(
376            Color::Ansi(AnsiColor::Red).to_ansi(),
377            Color::Ansi(AnsiColor::Red)
378        );
379        assert_eq!(Color::Indexed(42).to_ansi(), Color::Indexed(42));
380    }
381
382    #[test]
383    fn test_to_indexed_returns_indexed_variant() {
384        let c = Color::rgb(10, 20, 30);
385        assert!(matches!(c.to_indexed(), Color::Indexed(_)));
386    }
387
388    #[test]
389    fn test_to_ansi_returns_ansi_variant() {
390        let c = Color::rgb(10, 20, 30);
391        assert!(matches!(c.to_ansi(), Color::Ansi(_)));
392    }
393
394    #[test]
395    fn test_to_indexed_black_and_white() {
396        let black = Color::rgb(0, 0, 0);
397        assert_eq!(black.to_indexed(), Color::Indexed(0));
398
399        let white = Color::rgb(255, 255, 255);
400        assert_eq!(white.to_indexed(), Color::Indexed(15));
401    }
402
403    #[test]
404    fn test_to_ansi_pure_primaries() {
405        let red = Color::rgb(255, 0, 0);
406        assert_eq!(red.to_ansi(), Color::Ansi(AnsiColor::BrightRed));
407
408        let green = Color::rgb(0, 255, 0);
409        assert_eq!(green.to_ansi(), Color::Ansi(AnsiColor::BrightGreen));
410
411        // Pure (0, 0, 255) is closer to the standard Blue reference (0, 0, 238) than to
412        // BrightBlue (92, 92, 255), whose red/green components pull it further away.
413        let blue = Color::rgb(0, 0, 255);
414        assert_eq!(blue.to_ansi(), Color::Ansi(AnsiColor::Blue));
415
416        let black = Color::rgb(0, 0, 0);
417        assert_eq!(black.to_ansi(), Color::Ansi(AnsiColor::Black));
418    }
419
420    #[test]
421    fn test_to_ansi_all_16_roundtrip() {
422        // Each ANSI reference color, when quantized back to ANSI, should resolve to
423        // itself (it is by definition its own nearest neighbor in the ANSI palette).
424        for ansi in ANSI_COLORS {
425            let (r, g, b) = ansi.to_rgb();
426            let c = Color::rgb(r, g, b);
427            assert_eq!(c.to_ansi(), Color::Ansi(ansi), "ansi color {ansi:?}");
428        }
429    }
430
431    #[test]
432    fn test_to_indexed_mid_gray() {
433        let gray = Color::rgb(128, 128, 128);
434        // Should land in the grayscale ramp or cube, never panics or overflows.
435        assert!(matches!(gray.to_indexed(), Color::Indexed(_)));
436    }
437
438    #[test]
439    fn test_to_indexed_and_to_ansi_default_to_perceptual() {
440        // The no-argument forms are the `Quantize::Perceptual` ones, not merely "whichever
441        // metric happens to be compiled in".
442        for (r, g, b) in [
443            (210, 105, 30),
444            (250, 128, 114),
445            (135, 206, 235),
446            (0, 128, 128),
447        ] {
448            let c = Color::rgb(r, g, b);
449            assert_eq!(c.to_indexed(), c.to_indexed_with(Quantize::Perceptual));
450            assert_eq!(c.to_ansi(), c.to_ansi_with(Quantize::Perceptual));
451        }
452    }
453
454    #[test]
455    fn test_quantize_metrics_disagree() {
456        // Guards the point of the knob: if these ever agreed everywhere, one metric would be
457        // dead weight.
458        let chocolate = Color::rgb(210, 105, 30);
459        assert_eq!(
460            chocolate.to_ansi_with(Quantize::Perceptual),
461            Color::Ansi(AnsiColor::BrightRed)
462        );
463        assert_eq!(
464            chocolate.to_ansi_with(Quantize::Euclidean),
465            Color::Ansi(AnsiColor::Yellow)
466        );
467
468        let salmon = Color::rgb(250, 128, 114);
469        assert_eq!(
470            salmon.to_indexed_with(Quantize::Perceptual),
471            Color::Indexed(210)
472        );
473        assert_eq!(
474            salmon.to_indexed_with(Quantize::Euclidean),
475            Color::Indexed(209)
476        );
477    }
478
479    #[test]
480    fn test_quantize_non_rgb_passthrough_under_every_metric() {
481        for metric in [Quantize::Perceptual, Quantize::Euclidean] {
482            for color in [
483                Color::Default,
484                Color::Ansi(AnsiColor::Green),
485                Color::Indexed(42),
486            ] {
487                assert_eq!(color.to_indexed_with(metric), color, "{color:?} {metric:?}");
488                assert_eq!(color.to_ansi_with(metric), color, "{color:?} {metric:?}");
489            }
490        }
491    }
492
493    #[test]
494    fn test_quantize_default_is_perceptual() {
495        assert_eq!(Quantize::default(), Quantize::Perceptual);
496    }
497
498    #[test]
499    fn test_every_metric_maps_each_ansi_reference_color_to_itself() {
500        for metric in [Quantize::Perceptual, Quantize::Euclidean] {
501            for ansi in ANSI_COLORS {
502                let (r, g, b) = ansi.to_rgb();
503                let c = Color::rgb(r, g, b);
504                assert_eq!(
505                    c.to_ansi_with(metric),
506                    Color::Ansi(ansi),
507                    "ansi color {ansi:?} under {metric:?}"
508                );
509            }
510        }
511    }
512
513    #[test]
514    fn test_lerp() {
515        let red = Color::rgb(255, 0, 0);
516        let blue = Color::rgb(0, 0, 255);
517        let purple = Color::lerp(red, blue, 0.5);
518        // 127.5 rounds to 128 (round-to-nearest, ties away from zero).
519        assert_eq!(purple, Color::rgb(128, 0, 128));
520    }
521
522    #[test]
523    fn test_lerp_resolves_non_rgb() {
524        let red = Color::rgb(255, 0, 0);
525
526        // `Color::BLACK` (an `Ansi` variant) resolves to real black and blends normally, rather
527        // than short-circuiting to itself.
528        assert_eq!(Color::lerp(Color::BLACK, red, 1.0), red);
529        assert_eq!(Color::lerp(Color::BLACK, red, 0.0), Color::rgb(0, 0, 0));
530
531        // `Color::Ansi(AnsiColor::Black)` behaves identically to `Color::BLACK` (they're the same
532        // variant).
533        assert_eq!(Color::lerp(Color::Ansi(AnsiColor::Black), red, 1.0), red);
534
535        // `Color::Default` resolves to `(0, 0, 0)` as `a` and `(255, 255, 255)` as `b`.
536        assert_eq!(Color::lerp(Color::Default, red, 0.0), Color::rgb(0, 0, 0));
537        assert_eq!(
538            Color::lerp(red, Color::Default, 1.0),
539            Color::rgb(255, 255, 255)
540        );
541    }
542
543    #[test]
544    fn test_lighten_rgb() {
545        let c = Color::rgb(128, 64, 32);
546        let lighter = c.lighten(0.2);
547        assert_ne!(lighter, c);
548    }
549
550    #[test]
551    fn test_lighten_resolves_non_rgb() {
552        assert_ne!(Color::Default.lighten(0.5), Color::Default);
553        assert_ne!(
554            Color::Ansi(AnsiColor::Black).lighten(0.5),
555            Color::Ansi(AnsiColor::Black)
556        );
557        assert_ne!(Color::BLACK.lighten(0.5), Color::BLACK);
558    }
559
560    #[test]
561    fn test_darken_rgb() {
562        let c = Color::rgb(128, 64, 32);
563        let darker = c.darken(0.2);
564        assert_ne!(darker, c);
565    }
566
567    #[test]
568    fn test_darken_resolves_non_rgb() {
569        // `Color::Default` resolves to `(0, 0, 0)`, which darkening leaves at black.
570        assert_eq!(Color::Default.darken(0.5), Color::rgb(0, 0, 0));
571        // `Color::Ansi(AnsiColor::Black)` (and `Color::BLACK`) resolve to real black too.
572        assert_eq!(
573            Color::Ansi(AnsiColor::Black).darken(0.5),
574            Color::rgb(0, 0, 0)
575        );
576        assert_eq!(Color::BLACK.darken(0.5), Color::rgb(0, 0, 0));
577    }
578
579    #[test]
580    fn test_complement_red() {
581        let red = Color::rgb(255, 0, 0);
582        let cyan = red.complement();
583        assert!(cyan.to_srgb().is_some_and(|c| c.g > 0.9));
584        assert!(cyan.to_srgb().is_some_and(|c| c.b > 0.9));
585    }
586
587    #[test]
588    fn test_to_srgb_conversion() {
589        let c = Color::rgb(200, 100, 50);
590        let srgb = c.to_srgb().expect("Rgb variant should convert");
591        assert!((srgb.r - 200.0 / 255.0).abs() < 1e-6);
592        assert!((srgb.g - 100.0 / 255.0).abs() < 1e-6);
593        assert!((srgb.b - 50.0 / 255.0).abs() < 1e-6);
594    }
595
596    #[test]
597    fn test_to_srgb_non_rgb_returns_none() {
598        assert_eq!(Color::Default.to_srgb(), None);
599        assert_eq!(Color::Ansi(AnsiColor::Red).to_srgb(), None);
600        assert_eq!(Color::Indexed(42).to_srgb(), None);
601    }
602
603    #[test]
604    fn test_from_srgb_roundtrip() {
605        let srgb = Srgb::new(0.8, 0.4, 0.2);
606        let c = Color::from_srgb(srgb);
607        let back = c.to_srgb().expect("should convert back");
608        assert!((back.r - 0.8).abs() < 1.1 / 255.0);
609        assert!((back.g - 0.4).abs() < 1.1 / 255.0);
610        assert!((back.b - 0.2).abs() < 1.1 / 255.0);
611    }
612
613    #[test]
614    fn test_saturate_desaturate() {
615        let c = Color::rgb(128, 128, 128);
616        let saturated = c.saturate(0.5);
617        assert_ne!(saturated, c);
618
619        let desaturated = saturated.desaturate(0.5);
620        let diff = |a: u8, b: u8| (i16::from(a) - i16::from(b)).unsigned_abs();
621        assert!(
622            diff(
623                match desaturated {
624                    Color::Rgb { b, .. } => b,
625                    _ => 0,
626                },
627                128
628            ) <= 2
629        );
630    }
631
632    #[test]
633    fn test_saturate_desaturate_resolves_non_rgb() {
634        // Gray-ish ANSI colors have a saturation to increase/decrease; black (`Color::Default`'s
635        // resolved fallback and `Color::BLACK`) has none, but both must go through the same
636        // resolve-then-transform path rather than passing through unchanged.
637        assert_eq!(Color::Default.saturate(0.5), Color::rgb(0, 0, 0));
638        assert_eq!(
639            Color::Ansi(AnsiColor::Black).desaturate(0.5),
640            Color::rgb(0, 0, 0)
641        );
642        assert_eq!(Color::BLACK.saturate(0.5), Color::rgb(0, 0, 0));
643
644        let red = Color::Ansi(AnsiColor::Red);
645        assert_ne!(red.desaturate(0.5), red);
646    }
647
648    #[test]
649    fn test_complement_resolves_non_rgb() {
650        // Black's complement (in this HSL model) is still black, but it's computed through a
651        // real RGB resolution rather than being returned unchanged.
652        assert_eq!(Color::Default.complement(), Color::rgb(0, 0, 0));
653        assert_eq!(
654            Color::Ansi(AnsiColor::Black).complement(),
655            Color::rgb(0, 0, 0)
656        );
657        assert_eq!(Color::BLACK.complement(), Color::rgb(0, 0, 0));
658
659        let red_via_ansi = Color::Ansi(AnsiColor::Red).complement();
660        assert!(red_via_ansi.to_srgb().is_some_and(|c| c.g > 0.5));
661        assert!(red_via_ansi.to_srgb().is_some_and(|c| c.b > 0.5));
662    }
663
664    #[test]
665    fn test_lerp_endpoints() {
666        let red = Color::rgb(255, 0, 0);
667        let blue = Color::rgb(0, 0, 255);
668        assert_eq!(Color::lerp(red, blue, 0.0), red);
669        assert_eq!(Color::lerp(red, blue, 1.0), blue);
670    }
671
672    #[test]
673    fn test_darken_black_is_black() {
674        let black = Color::rgb(0, 0, 0);
675        assert_eq!(black.darken(0.5), black);
676    }
677}