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}