Skip to main content

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}