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}