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