retroglyph_core/color/ansi.rs
1//! The 16-color ANSI palette, and the shared indexed/ANSI quantization machinery `Color`'s
2//! `to_indexed`/`to_ansi`/`resolve_rgb` build on, including the [`Quantize`] metric that picks
3//! between them.
4//!
5//! The palette values here are the de-facto-standard xterm 256-color palette (the 16 ANSI
6//! defaults plus the 6x6x6 cube and 24-step gray ramp), the same numbers every other terminal
7//! matches. See the 8-bit color table at
8//! <https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit>. They are a fixed external palette, not
9//! values this crate is free to retune: changing one changes what every RGB input quantizes to and
10//! desyncs retroglyph's output from every other terminal's rendering of the same index.
11
12use gem::space::Srgb;
13
14use super::palette_oklab::PALETTE_OKLAB;
15
16/// The distance metric [`Color::to_indexed_with`](super::Color::to_indexed_with) and
17/// [`Color::to_ansi_with`](super::Color::to_ansi_with) use to find a palette entry's nearest
18/// neighbour.
19///
20/// # Examples
21///
22/// ```
23/// use retroglyph_core::color::{Color, Quantize};
24///
25/// let salmon = Color::Rgb { r: 250, g: 128, b: 114 };
26/// assert_eq!(salmon.to_indexed_with(Quantize::Perceptual), Color::Indexed(210));
27/// assert_eq!(salmon.to_indexed_with(Quantize::Euclidean), Color::Indexed(209));
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
30#[non_exhaustive]
31pub enum Quantize {
32 /// Euclidean distance in the Oklab perceptually-uniform color space.
33 ///
34 /// Matches human color perception far better than raw RGB distance, at the cost of
35 /// converting the input color to Oklab (three `powf` and three `cbrt`) on every call. The
36 /// palette side of the comparison is precomputed, so that conversion is the whole cost.
37 ///
38 /// The default, and what [`Color::to_indexed`](super::Color::to_indexed) and
39 /// [`Color::to_ansi`](super::Color::to_ansi) use.
40 #[default]
41 Perceptual,
42
43 /// Euclidean distance over the raw 8-bit RGB channels.
44 ///
45 /// Integer-only and allocation-free, and for [`Color::to_indexed_with`](super::Color::to_indexed_with)
46 /// it finds the 6x6x6 cube's nearest point by rounding each channel independently rather than
47 /// scanning the cube. Perceptually worse than [`Perceptual`](Self::Perceptual) (it
48 /// over-weights green and under-weights blue), but it's the rule most terminal tooling
49 /// applies, so it's the one to pick when the output has to agree with another tool's
50 /// downgrade.
51 Euclidean,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
55/// Standard 16-color ANSI palette.
56///
57/// Prefer `Ansi` colors when you want your game to respect the user's
58/// terminal color theme (e.g., Solarized, Nord, or custom themes).
59/// Use `Rgb` for fixed colors that must appear identical regardless of
60/// the user's terminal configuration.
61///
62/// # Examples
63///
64/// ```
65/// use retroglyph_core::color::{AnsiColor, Color};
66///
67/// let color = Color::Ansi(AnsiColor::Green);
68/// assert_eq!(AnsiColor::Green.to_index(), 2);
69/// assert_eq!(color, Color::GREEN);
70/// ```
71pub enum AnsiColor {
72 #[default]
73 /// Black.
74 Black = 0,
75 /// Red.
76 Red,
77 /// Green.
78 Green,
79 /// Yellow.
80 Yellow,
81 /// Blue.
82 Blue,
83 /// Magenta.
84 Magenta,
85 /// Cyan.
86 Cyan,
87 /// White.
88 White,
89 /// Bright Black.
90 BrightBlack,
91 /// Bright Red.
92 BrightRed,
93 /// Bright Green.
94 BrightGreen,
95 /// Bright Yellow.
96 BrightYellow,
97 /// Bright Blue.
98 BrightBlue,
99 /// Bright Magenta.
100 BrightMagenta,
101 /// Bright Cyan.
102 BrightCyan,
103 /// Bright White.
104 BrightWhite,
105}
106
107impl AnsiColor {
108 /// Returns the ANSI color code as a `u8` index.
109 #[must_use]
110 pub const fn to_index(self) -> u8 {
111 self as u8
112 }
113
114 /// Returns the standard xterm RGB values for this ANSI color.
115 ///
116 /// These are the classic xterm defaults, the same 16 reference colors used by
117 /// [`Color::to_indexed`](super::Color::to_indexed) and [`Color::to_ansi`](super::Color::to_ansi)
118 /// when quantizing RGB input. xterm's own defaults have shifted across versions, and a
119 /// terminal's actual theme may render these colors differently still.
120 #[must_use]
121 pub const fn to_rgb(self) -> (u8, u8, u8) {
122 match self {
123 Self::Black => (0, 0, 0),
124 Self::Red => (205, 0, 0),
125 Self::Green => (0, 205, 0),
126 Self::Yellow => (205, 205, 0),
127 Self::Blue => (0, 0, 238),
128 Self::Magenta => (205, 0, 205),
129 Self::Cyan => (0, 205, 205),
130 Self::White => (229, 229, 229),
131 Self::BrightBlack => (127, 127, 127),
132 Self::BrightRed => (255, 0, 0),
133 Self::BrightGreen => (0, 255, 0),
134 Self::BrightYellow => (255, 255, 0),
135 Self::BrightBlue => (92, 92, 255),
136 Self::BrightMagenta => (255, 0, 255),
137 Self::BrightCyan => (0, 255, 255),
138 Self::BrightWhite => (255, 255, 255),
139 }
140 }
141}
142
143/// All 16 [`AnsiColor`](crate::color::AnsiColor) variants in index order (0–15), for iterating the palette.
144pub(super) const ANSI_COLORS: [AnsiColor; 16] = [
145 AnsiColor::Black,
146 AnsiColor::Red,
147 AnsiColor::Green,
148 AnsiColor::Yellow,
149 AnsiColor::Blue,
150 AnsiColor::Magenta,
151 AnsiColor::Cyan,
152 AnsiColor::White,
153 AnsiColor::BrightBlack,
154 AnsiColor::BrightRed,
155 AnsiColor::BrightGreen,
156 AnsiColor::BrightYellow,
157 AnsiColor::BrightBlue,
158 AnsiColor::BrightMagenta,
159 AnsiColor::BrightCyan,
160 AnsiColor::BrightWhite,
161];
162
163/// The 6 steps used for each channel of the 256-color palette's 6x6x6 RGB cube
164/// (indices 16-231).
165///
166/// The five non-zero steps follow xterm's `55 + 40 * n` for `n` in `1..=5`; step 0 is a true 0,
167/// not `55 - 40`. Do not "regularize" these to evenly spaced values: they must match the xterm
168/// palette other terminals use.
169const CUBE_STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
170
171/// The 24 grayscale ramp values used by the 256-color palette (indices 232-255).
172///
173/// xterm's ramp: `8 + 10 * n` for `n` in `0..=23`, so it runs 8..=238 and never reaches pure
174/// black or pure white (those live in the cube and the ANSI set).
175const GRAYSCALE_RAMP: [u8; 24] = [
176 8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188, 198, 208,
177 218, 228, 238,
178];
179
180/// Returns the RGB value for a 256-color palette index (0–255).
181///
182/// Indices 0–15 are the 16 standard ANSI colors, 16–231 are the 6×6×6 RGB cube, and
183/// 232–255 are the grayscale ramp.
184///
185/// `const` and integer-only: [`Color::resolve_rgb`](super::Color::resolve_rgb) calls it for every
186/// [`Indexed`](super::Color::Indexed) tile a pixel backend draws, and it's also what generates
187/// [`PALETTE_OKLAB`].
188pub(super) const fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
189 if index < 16 {
190 ANSI_COLORS[index as usize].to_rgb()
191 } else if index < 232 {
192 let cube_index = index - 16;
193 let r = CUBE_STEPS[(cube_index / 36) as usize];
194 let g = CUBE_STEPS[((cube_index / 6) % 6) as usize];
195 let b = CUBE_STEPS[(cube_index % 6) as usize];
196 (r, g, b)
197 } else {
198 let gray = GRAYSCALE_RAMP[(index - 232) as usize];
199 (gray, gray, gray)
200 }
201}
202
203/// Rounds `value` to the nearest of the 6 [`CUBE_STEPS`], returning the step's index
204/// (0–5).
205///
206/// Ties (exactly halfway between two steps) round to the lower step: steps are
207/// scanned in ascending order and only a strictly closer step replaces the
208/// current best, so an equal-distance higher step never wins.
209fn nearest_cube_step(value: u8) -> u8 {
210 let value = i32::from(value);
211 let mut best_index = 0u8;
212 let mut best_distance = i32::MAX;
213 for (i, &step) in CUBE_STEPS.iter().enumerate() {
214 let distance = (value - i32::from(step)).abs();
215 if distance < best_distance {
216 best_distance = distance;
217 best_index = u8::try_from(i).unwrap_or(0);
218 }
219 }
220 best_index
221}
222
223/// Quantizes `(r, g, b)` to the nearest 256-color palette index using the 6×6×6 RGB
224/// cube, grayscale ramp, and the 16 ANSI colors, breaking ties by preferring the
225/// lower index.
226///
227/// Backs [`Quantize::Euclidean`] for [`Color::to_indexed_with`](super::Color::to_indexed_with).
228///
229/// Checks the 16 ANSI colors, the cube's single nearest point (found by rounding each
230/// channel independently), and the grayscale ramp's single nearest point, rather than
231/// scanning all 256 entries individually: rounding each channel independently already
232/// finds the cube's closest point, and likewise for the single-channel grayscale ramp.
233/// Candidates are checked in ascending index order and only replace the current best
234/// on strictly smaller distance, so ties naturally resolve to the lower index.
235fn cube_map_to_indexed(r: u8, g: u8, b: u8) -> u8 {
236 let mut best_index = 0u8;
237 let mut best_distance = u32::MAX;
238
239 // Candidate group 1: the 16 ANSI colors (indices 0-15), lowest indices first.
240 for (i, ansi) in ANSI_COLORS.iter().enumerate() {
241 let distance = gem::rgb::distance_sq((r, g, b), ansi.to_rgb());
242 if distance < best_distance {
243 best_distance = distance;
244 best_index = u8::try_from(i).unwrap_or(0);
245 }
246 }
247
248 // Candidate group 2: nearest point in the 6x6x6 cube (indices 16-231).
249 let cube_r = nearest_cube_step(r);
250 let cube_g = nearest_cube_step(g);
251 let cube_b = nearest_cube_step(b);
252 let cube_index = 16 + 36 * cube_r + 6 * cube_g + cube_b;
253 let cube_rgb = (
254 CUBE_STEPS[cube_r as usize],
255 CUBE_STEPS[cube_g as usize],
256 CUBE_STEPS[cube_b as usize],
257 );
258 let cube_distance = gem::rgb::distance_sq((r, g, b), cube_rgb);
259 if cube_distance < best_distance {
260 best_distance = cube_distance;
261 best_index = cube_index;
262 }
263
264 // Candidate group 3: nearest grayscale ramp entry (indices 232-255).
265 for (i, &gray) in GRAYSCALE_RAMP.iter().enumerate() {
266 let distance = gem::rgb::distance_sq((r, g, b), (gray, gray, gray));
267 if distance < best_distance {
268 best_distance = distance;
269 best_index = 232 + u8::try_from(i).unwrap_or(0);
270 }
271 }
272
273 best_index
274}
275
276/// Quantizes `(r, g, b)` to the nearest of the 16 standard ANSI colors, using
277/// euclidean RGB distance and breaking ties by preferring the lower index.
278///
279/// Backs [`Quantize::Euclidean`] for [`Color::to_ansi_with`](super::Color::to_ansi_with).
280fn cube_map_to_ansi(r: u8, g: u8, b: u8) -> AnsiColor {
281 let mut best = AnsiColor::Black;
282 let mut best_distance = u32::MAX;
283 for ansi in ANSI_COLORS {
284 let distance = gem::rgb::distance_sq((r, g, b), ansi.to_rgb());
285 if distance < best_distance {
286 best_distance = distance;
287 best = ansi;
288 }
289 }
290 best
291}
292
293/// Converts an 8-bit RGB channel triplet to `gem::space::Srgb`, the shared conversion behind
294/// every `Srgb::new(f32::from(r) / 255.0, ...)` call site in this module.
295pub(super) fn rgb_to_srgb(r: u8, g: u8, b: u8) -> Srgb {
296 Srgb::new(
297 f32::from(r) / 255.0,
298 f32::from(g) / 255.0,
299 f32::from(b) / 255.0,
300 )
301}
302
303/// Converts an 8-bit RGB channel triplet to Oklab.
304fn rgb_to_oklab(r: u8, g: u8, b: u8) -> gem::space::Oklab {
305 gem::space::Oklab::from(rgb_to_srgb(r, g, b))
306}
307
308/// Quantizes `(r, g, b)` to the nearest 256-color palette index using perceptual
309/// (Oklab) distance, breaking ties by preferring the lower index.
310fn perceptual_to_indexed(r: u8, g: u8, b: u8) -> u8 {
311 let target = rgb_to_oklab(r, g, b);
312 let mut best_index = 0u8;
313 let mut best_distance = f32::MAX;
314 for (index, &entry) in PALETTE_OKLAB.iter().enumerate() {
315 let distance = target.distance_sq(entry);
316 if distance < best_distance {
317 best_distance = distance;
318 best_index = u8::try_from(index).unwrap_or(u8::MAX);
319 }
320 }
321 best_index
322}
323
324/// Quantizes `(r, g, b)` to the nearest of the 16 standard ANSI colors using
325/// perceptual (Oklab) distance, breaking ties by preferring the lower index.
326///
327/// Searches [`PALETTE_OKLAB`]'s first 16 entries rather than a table of its own: the 256-color
328/// palette opens with the 16 ANSI colors in [`ANSI_COLORS`] order, so those entries already are
329/// the ANSI palette's Oklab values.
330fn perceptual_to_ansi(r: u8, g: u8, b: u8) -> AnsiColor {
331 let target = rgb_to_oklab(r, g, b);
332 let mut best = AnsiColor::Black;
333 let mut best_distance = f32::MAX;
334 for (ansi, &entry) in ANSI_COLORS.iter().zip(&PALETTE_OKLAB) {
335 let distance = target.distance_sq(entry);
336 if distance < best_distance {
337 best_distance = distance;
338 best = *ansi;
339 }
340 }
341 best
342}
343
344/// Quantizes `(r, g, b)` to the nearest 256-color palette index under `metric`, breaking ties by
345/// preferring the lower index.
346pub(super) fn rgb_to_indexed(r: u8, g: u8, b: u8, metric: Quantize) -> u8 {
347 match metric {
348 Quantize::Euclidean => cube_map_to_indexed(r, g, b),
349 // `Quantize` is `#[non_exhaustive]`: an unrecognized future metric falls back to the
350 // default rather than failing to compile.
351 _ => perceptual_to_indexed(r, g, b),
352 }
353}
354
355/// Quantizes `(r, g, b)` to the nearest of the 16 standard ANSI colors under `metric`, breaking
356/// ties by preferring the lower index.
357pub(super) fn rgb_to_ansi(r: u8, g: u8, b: u8, metric: Quantize) -> AnsiColor {
358 match metric {
359 Quantize::Euclidean => cube_map_to_ansi(r, g, b),
360 // See `rgb_to_indexed` above for why this isn't an exhaustive match.
361 _ => perceptual_to_ansi(r, g, b),
362 }
363}
364
365/// Error returned when a `u8` value has no corresponding [`AnsiColor`](crate::color::AnsiColor).
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub struct InvalidAnsiIndex(pub u8);
368
369impl core::fmt::Display for InvalidAnsiIndex {
370 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
371 write!(f, "invalid ANSI color index: {}", self.0)
372 }
373}
374
375impl core::error::Error for InvalidAnsiIndex {}
376
377impl TryFrom<u8> for AnsiColor {
378 type Error = InvalidAnsiIndex;
379
380 fn try_from(v: u8) -> Result<Self, Self::Error> {
381 match v {
382 0 => Ok(Self::Black),
383 1 => Ok(Self::Red),
384 2 => Ok(Self::Green),
385 3 => Ok(Self::Yellow),
386 4 => Ok(Self::Blue),
387 5 => Ok(Self::Magenta),
388 6 => Ok(Self::Cyan),
389 7 => Ok(Self::White),
390 8 => Ok(Self::BrightBlack),
391 9 => Ok(Self::BrightRed),
392 10 => Ok(Self::BrightGreen),
393 11 => Ok(Self::BrightYellow),
394 12 => Ok(Self::BrightBlue),
395 13 => Ok(Self::BrightMagenta),
396 14 => Ok(Self::BrightCyan),
397 15 => Ok(Self::BrightWhite),
398 _ => Err(InvalidAnsiIndex(v)),
399 }
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_ansi_values() {
409 assert_eq!(AnsiColor::Red as u8, 1);
410 assert_eq!(AnsiColor::BrightWhite as u8, 15);
411 }
412
413 #[test]
414 fn test_ansi_try_from_roundtrip() {
415 for i in 0u8..16 {
416 let color = AnsiColor::try_from(i).expect("should be valid");
417 assert_eq!(color.to_index(), i);
418 }
419 }
420
421 #[test]
422 fn test_ansi_try_from_invalid() {
423 assert_eq!(AnsiColor::try_from(16), Err(InvalidAnsiIndex(16)));
424 assert_eq!(AnsiColor::try_from(255), Err(InvalidAnsiIndex(255)));
425 }
426
427 // ── the generated Oklab palette table ────────────────────────────────────────────
428
429 /// The largest per-channel deviation tolerated between a [`PALETTE_OKLAB`] entry and the same
430 /// color converted at runtime.
431 ///
432 /// Not zero, because the table's literals were generated under one float backend and the
433 /// comparison runs under whichever of `std`/`libm` the build selected; those disagree by a few
434 /// ULP in `powf`/`cbrt`. Far tighter than the gap between any two palette entries, so a
435 /// genuinely wrong or stale entry still fails.
436 const PALETTE_EPSILON: f32 = 1e-5;
437
438 #[test]
439 fn test_palette_oklab_matches_computed_table() {
440 for (index, &entry) in PALETTE_OKLAB.iter().enumerate() {
441 let (r, g, b) = indexed_to_rgb(u8::try_from(index).expect("index is 0..256"));
442 let computed = rgb_to_oklab(r, g, b);
443 for (label, generated, computed) in [
444 ("l", entry.l, computed.l),
445 ("a", entry.a, computed.a),
446 ("b", entry.b, computed.b),
447 ] {
448 assert!(
449 (generated - computed).abs() <= PALETTE_EPSILON,
450 "palette entry {index} channel {label}: {generated} != {computed}"
451 );
452 }
453 }
454 }
455
456 /// Quantization itself, not just the table, must be unchanged by using generated literals:
457 /// a nearest-neighbour search only cares about the *ordering* of distances, so an entry could
458 /// drift within [`PALETTE_EPSILON`] and still flip a near-tie.
459 #[test]
460 fn test_palette_oklab_quantizes_identically_to_computed_table() {
461 let computed: [gem::space::Oklab; 256] = core::array::from_fn(|i| {
462 let (r, g, b) = indexed_to_rgb(u8::try_from(i).expect("index is 0..256"));
463 rgb_to_oklab(r, g, b)
464 });
465
466 // Every 17th value per channel: the 16^3 grid hits both palette entries and the midpoints
467 // between them, where a tie is most likely to flip.
468 for r in (0..=255u8).step_by(17) {
469 for g in (0..=255u8).step_by(17) {
470 for b in (0..=255u8).step_by(17) {
471 let target = rgb_to_oklab(r, g, b);
472 let nearest = |table: &[gem::space::Oklab; 256]| {
473 let mut best = (0usize, f32::MAX);
474 for (i, &entry) in table.iter().enumerate() {
475 let distance = target.distance_sq(entry);
476 if distance < best.1 {
477 best = (i, distance);
478 }
479 }
480 best.0
481 };
482 assert_eq!(
483 nearest(&PALETTE_OKLAB),
484 nearest(&computed),
485 "rgb({r}, {g}, {b})"
486 );
487 }
488 }
489 }
490 }
491
492 // ── `Quantize::Euclidean`'s cube-mapping ────────────────────────────────────────────
493
494 #[test]
495 fn test_nearest_cube_step_boundaries() {
496 assert_eq!(nearest_cube_step(0), 0);
497 assert_eq!(nearest_cube_step(255), 5);
498 assert_eq!(nearest_cube_step(95), 1);
499 assert_eq!(nearest_cube_step(135), 2);
500 }
501
502 #[test]
503 fn test_indexed_to_rgb_ansi_range() {
504 assert_eq!(indexed_to_rgb(0), (0, 0, 0));
505 assert_eq!(indexed_to_rgb(15), (255, 255, 255));
506 }
507
508 #[test]
509 fn test_indexed_to_rgb_cube_range() {
510 // Index 16 is the cube origin (0, 0, 0).
511 assert_eq!(indexed_to_rgb(16), (0, 0, 0));
512 // Index 231 is the cube's opposite corner (255, 255, 255).
513 assert_eq!(indexed_to_rgb(231), (255, 255, 255));
514 }
515
516 #[test]
517 fn test_indexed_to_rgb_grayscale_range() {
518 assert_eq!(indexed_to_rgb(232), (8, 8, 8));
519 assert_eq!(indexed_to_rgb(255), (238, 238, 238));
520 }
521
522 #[test]
523 fn test_cube_map_to_indexed_pure_black() {
524 assert_eq!(cube_map_to_indexed(0, 0, 0), 0);
525 }
526
527 #[test]
528 fn test_cube_map_to_indexed_pure_white() {
529 assert_eq!(cube_map_to_indexed(255, 255, 255), 15);
530 }
531
532 #[test]
533 fn test_cube_map_to_indexed_cube_interior() {
534 // A color exactly on a cube step should map to that exact cube index.
535 // (95, 135, 175) -> cube coords (1, 2, 3) -> 16 + 36*1 + 6*2 + 3 = 67.
536 assert_eq!(cube_map_to_indexed(95, 135, 175), 67);
537 }
538
539 #[test]
540 fn test_cube_map_to_ansi_matches_reference() {
541 for ansi in ANSI_COLORS {
542 let (r, g, b) = ansi.to_rgb();
543 assert_eq!(cube_map_to_ansi(r, g, b), ansi, "ansi color {ansi:?}");
544 }
545 }
546
547 #[test]
548 fn test_rgb_distance_sq_symmetry() {
549 let a = (10, 20, 30);
550 let b = (200, 100, 50);
551 assert_eq!(gem::rgb::distance_sq(a, b), gem::rgb::distance_sq(b, a));
552 }
553
554 #[test]
555 fn test_rgb_distance_sq_zero_for_identical() {
556 assert_eq!(gem::rgb::distance_sq((1, 2, 3), (1, 2, 3)), 0);
557 }
558}