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(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}