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(250, 128, 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 ANSI_TABLE[self as usize].2
123 }
124}
125
126/// Every [`AnsiColor`] variant's canonical [`Display`](core::fmt::Display) name and xterm
127/// reference RGB, in discriminant order (0-15).
128///
129/// The single source of truth [`AnsiColor::to_rgb`], [`TryFrom<u8>`](AnsiColor), and the ANSI
130/// name/parse pair in `parse.rs` all derive from, so a name or RGB value only ever needs
131/// transcribing once.
132pub(super) const ANSI_TABLE: [(AnsiColor, &str, (u8, u8, u8)); 16] = [
133 (AnsiColor::Black, "black", (0, 0, 0)),
134 (AnsiColor::Red, "red", (205, 0, 0)),
135 (AnsiColor::Green, "green", (0, 205, 0)),
136 (AnsiColor::Yellow, "yellow", (205, 205, 0)),
137 (AnsiColor::Blue, "blue", (0, 0, 238)),
138 (AnsiColor::Magenta, "magenta", (205, 0, 205)),
139 (AnsiColor::Cyan, "cyan", (0, 205, 205)),
140 (AnsiColor::White, "white", (229, 229, 229)),
141 (AnsiColor::BrightBlack, "bright-black", (127, 127, 127)),
142 (AnsiColor::BrightRed, "bright-red", (255, 0, 0)),
143 (AnsiColor::BrightGreen, "bright-green", (0, 255, 0)),
144 (AnsiColor::BrightYellow, "bright-yellow", (255, 255, 0)),
145 (AnsiColor::BrightBlue, "bright-blue", (92, 92, 255)),
146 (AnsiColor::BrightMagenta, "bright-magenta", (255, 0, 255)),
147 (AnsiColor::BrightCyan, "bright-cyan", (0, 255, 255)),
148 (AnsiColor::BrightWhite, "bright-white", (255, 255, 255)),
149];
150
151/// All 16 [`AnsiColor`](crate::color::AnsiColor) variants in index order (0–15), for iterating the palette.
152pub(super) const ANSI_COLORS: [AnsiColor; 16] = {
153 let mut colors = [AnsiColor::Black; 16];
154 let mut i = 0;
155 while i < ANSI_TABLE.len() {
156 colors[i] = ANSI_TABLE[i].0;
157 i += 1;
158 }
159 colors
160};
161
162/// The 6 steps used for each channel of the 256-color palette's 6x6x6 RGB cube
163/// (indices 16-231).
164///
165/// The five non-zero steps follow xterm's `55 + 40 * n` for `n` in `1..=5`; step 0 is a true 0,
166/// not `55 - 40`. Do not "regularize" these to evenly spaced values: they must match the xterm
167/// palette other terminals use.
168const CUBE_STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
169
170/// The 24 grayscale ramp values used by the 256-color palette (indices 232-255).
171///
172/// xterm's ramp: `8 + 10 * n` for `n` in `0..=23`, so it runs 8..=238 and never reaches pure
173/// black or pure white (those live in the cube and the ANSI set).
174const GRAYSCALE_RAMP: [u8; 24] = [
175 8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188, 198, 208,
176 218, 228, 238,
177];
178
179/// Returns the RGB value for a 256-color palette index (0–255).
180///
181/// Indices 0–15 are the 16 standard ANSI colors, 16–231 are the 6×6×6 RGB cube, and
182/// 232–255 are the grayscale ramp.
183///
184/// `const` and integer-only: [`Color::resolve_rgb`](super::Color::resolve_rgb) calls it for every
185/// [`Indexed`](super::Color::Indexed) tile a pixel backend draws, and it's also what generates
186/// [`PALETTE_OKLAB`].
187pub(super) const fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
188 if index < 16 {
189 ANSI_COLORS[index as usize].to_rgb()
190 } else if index < 232 {
191 let cube_index = index - 16;
192 let r = CUBE_STEPS[(cube_index / 36) as usize];
193 let g = CUBE_STEPS[((cube_index / 6) % 6) as usize];
194 let b = CUBE_STEPS[(cube_index % 6) as usize];
195 (r, g, b)
196 } else {
197 let gray = GRAYSCALE_RAMP[(index - 232) as usize];
198 (gray, gray, gray)
199 }
200}
201
202/// Picks the candidate with the smallest `D`, preferring the lower index on ties (strict `<`
203/// only). Index candidates with an inclusive range (`data.zip(start..=u8::MAX)`), not
204/// `start..`: an open `RangeFrom<u8>` computes one-past-the-end on the call that yields 255
205/// itself and overflows, since `u8` has no representable "256" to stop at.
206fn nearest<D: PartialOrd>(candidates: impl IntoIterator<Item = (u8, D)>) -> u8 {
207 candidates
208 .into_iter()
209 .reduce(|a, b| if b.1 < a.1 { b } else { a })
210 .map_or(0, |(i, _)| i)
211}
212
213/// Rounds `value` to the nearest of the 6 [`CUBE_STEPS`], returning the step's index
214/// (0–5).
215///
216/// Ties (exactly halfway between two steps) round to the lower step: see [`nearest`].
217fn nearest_cube_step(value: u8) -> u8 {
218 let value = i32::from(value);
219 nearest(
220 CUBE_STEPS
221 .iter()
222 .zip(0u8..)
223 .map(|(&step, i)| (i, (value - i32::from(step)).abs())),
224 )
225}
226
227/// Quantizes `(r, g, b)` to the nearest 256-color palette index using the 6×6×6 RGB
228/// cube, grayscale ramp, and the 16 ANSI colors, breaking ties by preferring the
229/// lower index.
230///
231/// Backs [`Quantize::Euclidean`] for [`Color::to_indexed_with`](super::Color::to_indexed_with).
232///
233/// Checks the 16 ANSI colors, the cube's single nearest point (found by rounding each
234/// channel independently), and the grayscale ramp's single nearest point, rather than
235/// scanning all 256 entries individually: rounding each channel independently already
236/// finds the cube's closest point, and likewise for the single-channel grayscale ramp.
237/// Candidates are passed to [`nearest`] in ascending index order (ANSI, cube, ramp), so
238/// ties naturally resolve to the lower index.
239fn cube_map_to_indexed(r: u8, g: u8, b: u8) -> u8 {
240 // Nearest point in the 6x6x6 cube (candidate group 2, indices 16-231).
241 let cube_r = nearest_cube_step(r);
242 let cube_g = nearest_cube_step(g);
243 let cube_b = nearest_cube_step(b);
244 let cube_index = 16 + 36 * cube_r + 6 * cube_g + cube_b;
245 let cube_rgb = (
246 CUBE_STEPS[cube_r as usize],
247 CUBE_STEPS[cube_g as usize],
248 CUBE_STEPS[cube_b as usize],
249 );
250
251 nearest(
252 // Candidate group 1: the 16 ANSI colors (indices 0-15), lowest indices first.
253 ANSI_COLORS
254 .iter()
255 .zip(0u8..)
256 .map(|(ansi, i)| (i, gem::rgb::distance_sq((r, g, b), ansi.to_rgb())))
257 .chain(core::iter::once((
258 cube_index,
259 gem::rgb::distance_sq((r, g, b), cube_rgb),
260 )))
261 // Candidate group 3: nearest grayscale ramp entry (indices 232-255).
262 .chain(
263 GRAYSCALE_RAMP
264 .iter()
265 .zip(232u8..=u8::MAX)
266 .map(|(&gray, i)| (i, gem::rgb::distance_sq((r, g, b), (gray, gray, gray)))),
267 ),
268 )
269}
270
271/// Quantizes `(r, g, b)` to the nearest of the 16 standard ANSI colors, using
272/// euclidean RGB distance and breaking ties by preferring the lower index.
273///
274/// Backs [`Quantize::Euclidean`] for [`Color::to_ansi_with`](super::Color::to_ansi_with).
275fn cube_map_to_ansi(r: u8, g: u8, b: u8) -> AnsiColor {
276 let i = nearest(
277 ANSI_COLORS
278 .iter()
279 .zip(0u8..)
280 .map(|(ansi, i)| (i, gem::rgb::distance_sq((r, g, b), ansi.to_rgb()))),
281 );
282 ANSI_COLORS[i as usize]
283}
284
285/// Converts an 8-bit RGB channel triplet to `gem::space::Srgb`, the shared conversion behind
286/// every `Srgb::new(f32::from(r) / 255.0, ...)` call site in this module.
287pub(super) fn rgb_to_srgb(r: u8, g: u8, b: u8) -> Srgb {
288 Srgb::new(
289 f32::from(r) / 255.0,
290 f32::from(g) / 255.0,
291 f32::from(b) / 255.0,
292 )
293}
294
295/// Converts an 8-bit RGB channel triplet to Oklab.
296fn rgb_to_oklab(r: u8, g: u8, b: u8) -> gem::space::Oklab {
297 gem::space::Oklab::from(rgb_to_srgb(r, g, b))
298}
299
300/// Quantizes `(r, g, b)` to the nearest 256-color palette index using perceptual
301/// (Oklab) distance, breaking ties by preferring the lower index.
302fn perceptual_to_indexed(r: u8, g: u8, b: u8) -> u8 {
303 let target = rgb_to_oklab(r, g, b);
304 nearest(
305 PALETTE_OKLAB
306 .iter()
307 .zip(0u8..=u8::MAX)
308 .map(|(&entry, i)| (i, target.distance_sq(entry))),
309 )
310}
311
312/// Quantizes `(r, g, b)` to the nearest of the 16 standard ANSI colors using
313/// perceptual (Oklab) distance, breaking ties by preferring the lower index.
314///
315/// Searches [`PALETTE_OKLAB`]'s first 16 entries rather than a table of its own: the 256-color
316/// palette opens with the 16 ANSI colors in [`ANSI_COLORS`] order, so those entries already are
317/// the ANSI palette's Oklab values.
318fn perceptual_to_ansi(r: u8, g: u8, b: u8) -> AnsiColor {
319 let target = rgb_to_oklab(r, g, b);
320 let i = nearest(
321 PALETTE_OKLAB
322 .iter()
323 .zip(0u8..)
324 .take(ANSI_COLORS.len())
325 .map(|(&entry, i)| (i, target.distance_sq(entry))),
326 );
327 ANSI_COLORS[i as usize]
328}
329
330/// Quantizes `(r, g, b)` to the nearest 256-color palette index under `metric`, breaking ties by
331/// preferring the lower index.
332pub(super) fn rgb_to_indexed(r: u8, g: u8, b: u8, metric: Quantize) -> u8 {
333 match metric {
334 Quantize::Euclidean => cube_map_to_indexed(r, g, b),
335 // `Quantize` is `#[non_exhaustive]`: an unrecognized future metric falls back to the
336 // default rather than failing to compile.
337 _ => perceptual_to_indexed(r, g, b),
338 }
339}
340
341/// Quantizes `(r, g, b)` to the nearest of the 16 standard ANSI colors under `metric`, breaking
342/// ties by preferring the lower index.
343pub(super) fn rgb_to_ansi(r: u8, g: u8, b: u8, metric: Quantize) -> AnsiColor {
344 match metric {
345 Quantize::Euclidean => cube_map_to_ansi(r, g, b),
346 // See `rgb_to_indexed` above for why this isn't an exhaustive match.
347 _ => perceptual_to_ansi(r, g, b),
348 }
349}
350
351/// Error returned when a `u8` value has no corresponding [`AnsiColor`](crate::color::AnsiColor).
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct InvalidAnsiIndex(pub u8);
354
355impl core::fmt::Display for InvalidAnsiIndex {
356 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
357 write!(f, "invalid ANSI color index: {}", self.0)
358 }
359}
360
361impl core::error::Error for InvalidAnsiIndex {}
362
363impl TryFrom<u8> for AnsiColor {
364 type Error = InvalidAnsiIndex;
365
366 fn try_from(v: u8) -> Result<Self, Self::Error> {
367 ANSI_TABLE
368 .get(v as usize)
369 .map(|entry| entry.0)
370 .ok_or(InvalidAnsiIndex(v))
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_ansi_values() {
380 assert_eq!(AnsiColor::Red as u8, 1);
381 assert_eq!(AnsiColor::BrightWhite as u8, 15);
382 }
383
384 #[test]
385 fn test_ansi_try_from_roundtrip() {
386 for i in 0u8..16 {
387 let color = AnsiColor::try_from(i).expect("should be valid");
388 assert_eq!(color.to_index(), i);
389 }
390 }
391
392 #[test]
393 fn test_ansi_try_from_invalid() {
394 assert_eq!(AnsiColor::try_from(16), Err(InvalidAnsiIndex(16)));
395 assert_eq!(AnsiColor::try_from(255), Err(InvalidAnsiIndex(255)));
396 }
397
398 // ── the generated Oklab palette table ────────────────────────────────────────────
399
400 /// The largest per-channel deviation tolerated between a [`PALETTE_OKLAB`] entry and the same
401 /// color converted at runtime.
402 ///
403 /// Not zero, because the table's literals were generated under one float backend and the
404 /// comparison runs under whichever of `std`/`libm` the build selected; those disagree by a few
405 /// ULP in `powf`/`cbrt`. Far tighter than the gap between any two palette entries, so a
406 /// genuinely wrong or stale entry still fails.
407 const PALETTE_EPSILON: f32 = 1e-5;
408
409 #[test]
410 fn test_palette_oklab_matches_computed_table() {
411 for (index, &entry) in PALETTE_OKLAB.iter().enumerate() {
412 let (r, g, b) = indexed_to_rgb(u8::try_from(index).expect("index is 0..256"));
413 let computed = rgb_to_oklab(r, g, b);
414 for (label, generated, computed) in [
415 ("l", entry.l, computed.l),
416 ("a", entry.a, computed.a),
417 ("b", entry.b, computed.b),
418 ] {
419 assert!(
420 (generated - computed).abs() <= PALETTE_EPSILON,
421 "palette entry {index} channel {label}: {generated} != {computed}"
422 );
423 }
424 }
425 }
426
427 /// Quantization itself, not just the table, must be unchanged by using generated literals:
428 /// a nearest-neighbour search only cares about the *ordering* of distances, so an entry could
429 /// drift within [`PALETTE_EPSILON`] and still flip a near-tie.
430 #[test]
431 fn test_palette_oklab_quantizes_identically_to_computed_table() {
432 let computed: [gem::space::Oklab; 256] = core::array::from_fn(|i| {
433 let (r, g, b) = indexed_to_rgb(u8::try_from(i).expect("index is 0..256"));
434 rgb_to_oklab(r, g, b)
435 });
436
437 // Every 17th value per channel: the 16^3 grid hits both palette entries and the midpoints
438 // between them, where a tie is most likely to flip.
439 for r in (0..=255u8).step_by(17) {
440 for g in (0..=255u8).step_by(17) {
441 for b in (0..=255u8).step_by(17) {
442 let target = rgb_to_oklab(r, g, b);
443 let nearest = |table: &[gem::space::Oklab; 256]| {
444 let mut best = (0usize, f32::MAX);
445 for (i, &entry) in table.iter().enumerate() {
446 let distance = target.distance_sq(entry);
447 if distance < best.1 {
448 best = (i, distance);
449 }
450 }
451 best.0
452 };
453 assert_eq!(
454 nearest(&PALETTE_OKLAB),
455 nearest(&computed),
456 "rgb({r}, {g}, {b})"
457 );
458 }
459 }
460 }
461 }
462
463 // ── `Quantize::Euclidean`'s cube-mapping ────────────────────────────────────────────
464
465 #[test]
466 fn test_nearest_cube_step_boundaries() {
467 assert_eq!(nearest_cube_step(0), 0);
468 assert_eq!(nearest_cube_step(255), 5);
469 assert_eq!(nearest_cube_step(95), 1);
470 assert_eq!(nearest_cube_step(135), 2);
471 }
472
473 #[test]
474 fn test_indexed_to_rgb_ansi_range() {
475 assert_eq!(indexed_to_rgb(0), (0, 0, 0));
476 assert_eq!(indexed_to_rgb(15), (255, 255, 255));
477 }
478
479 #[test]
480 fn test_indexed_to_rgb_cube_range() {
481 // Index 16 is the cube origin (0, 0, 0).
482 assert_eq!(indexed_to_rgb(16), (0, 0, 0));
483 // Index 231 is the cube's opposite corner (255, 255, 255).
484 assert_eq!(indexed_to_rgb(231), (255, 255, 255));
485 }
486
487 #[test]
488 fn test_indexed_to_rgb_grayscale_range() {
489 assert_eq!(indexed_to_rgb(232), (8, 8, 8));
490 assert_eq!(indexed_to_rgb(255), (238, 238, 238));
491 }
492
493 #[test]
494 fn test_cube_map_to_indexed_pure_black() {
495 assert_eq!(cube_map_to_indexed(0, 0, 0), 0);
496 }
497
498 #[test]
499 fn test_cube_map_to_indexed_pure_white() {
500 assert_eq!(cube_map_to_indexed(255, 255, 255), 15);
501 }
502
503 #[test]
504 fn test_cube_map_to_indexed_cube_interior() {
505 // A color exactly on a cube step should map to that exact cube index.
506 // (95, 135, 175) -> cube coords (1, 2, 3) -> 16 + 36*1 + 6*2 + 3 = 67.
507 assert_eq!(cube_map_to_indexed(95, 135, 175), 67);
508 }
509
510 #[test]
511 fn test_cube_map_to_ansi_matches_reference() {
512 for ansi in ANSI_COLORS {
513 let (r, g, b) = ansi.to_rgb();
514 assert_eq!(cube_map_to_ansi(r, g, b), ansi, "ansi color {ansi:?}");
515 }
516 }
517
518 #[test]
519 fn test_rgb_distance_sq_symmetry() {
520 let a = (10, 20, 30);
521 let b = (200, 100, 50);
522 assert_eq!(gem::rgb::distance_sq(a, b), gem::rgb::distance_sq(b, a));
523 }
524
525 #[test]
526 fn test_rgb_distance_sq_zero_for_identical() {
527 assert_eq!(gem::rgb::distance_sq((1, 2, 3), (1, 2, 3)), 0);
528 }
529}