eger 1.2.0

A high-level, parallel ASCII-art media pipeline built on top of iascii: images, video, GIFs, dithering, animated text/banners, and procedural illusions, all rendered to ANSI.
Documentation
//! Shared ANSI color-palette quantization helpers.
//!
//! Both [`crate::dither`] (error-diffusion / ordered dithering down to a
//! target [`ColorDepth`]) and [`crate::segment`] (matching a styled
//! segment's *unstyled* neighbor cells to the same palette
//! `iascii::render::render_ansi` would have picked, so a partially-styled
//! frame looks visually consistent) need to answer the same question:
//! "given a 24-bit color and a [`ColorDepth`], what's the closest
//! displayable color, and what escape sequence draws it?".
//!
//! `iascii::render::ansi` answers that question too, but its palette
//! tables and nearest-color search are private to that crate. This module
//! reimplements the same standard formulas (the fixed 16-color ANSI
//! palette; the xterm 6x6x6 color cube plus 24-step grayscale ramp for
//! 256-color) independently, so results match what plain (unstyled)
//! `iascii` output would show at the same depth.

use iascii::render::ColorDepth;

use crate::fast_ansi;

pub(crate) type Rgb8 = (u8, u8, u8);

/// Standard 16-color ANSI palette, in the same order as the SGR 30-37 /
/// 90-97 foreground codes.
pub(crate) static ANSI16_PALETTE: [Rgb8; 16] = [
    (0, 0, 0),
    (128, 0, 0),
    (0, 128, 0),
    (128, 128, 0),
    (0, 0, 128),
    (128, 0, 128),
    (0, 128, 128),
    (192, 192, 192),
    (128, 128, 128),
    (255, 0, 0),
    (0, 255, 0),
    (255, 255, 0),
    (0, 0, 255),
    (255, 0, 255),
    (0, 255, 255),
    (255, 255, 255),
];

const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];

/// Nearest of the 16 standard ANSI colors to `c`.
pub(crate) fn nearest_ansi16(c: Rgb8) -> u8 {
    fast_ansi::nearest_ansi16(c.0, c.1, c.2)
}

/// The actual displayed color for ANSI-16 index `idx` (0..=15).
pub(crate) fn ansi16_rgb(idx: u8) -> Rgb8 {
    ANSI16_PALETTE[(idx as usize).min(15)]
}

/// Nearest xterm 256-color index (the 16 base colors are left out of the
/// search, matching `iascii`'s own approach of only choosing between the
/// 6x6x6 cube (16..=231) and the grayscale ramp (232..=255)).
pub(crate) fn nearest_ansi256(c: Rgb8) -> u8 {
    fast_ansi::nearest_ansi256(c.0, c.1, c.2)
}

/// The actual displayed color for xterm 256-color index `idx`.
pub(crate) fn ansi256_rgb(idx: u8) -> Rgb8 {
    if idx >= 232 {
        let v = 8 + (idx - 232) * 10;
        (v, v, v)
    } else if idx >= 16 {
        let i = idx - 16;
        let r = CUBE_LEVELS[(i / 36) as usize];
        let g = CUBE_LEVELS[((i / 6) % 6) as usize];
        let b = CUBE_LEVELS[(i % 6) as usize];
        (r, g, b)
    } else {
        ansi16_rgb(idx)
    }
}

/// Posterizes one 8-bit channel to `levels` evenly spaced steps
/// (`levels >= 2`).
pub(crate) fn posterize_channel(v: u8, levels: u8) -> u8 {
    let levels = levels.max(2) as f32;
    let step = 255.0 / (levels - 1.0);
    ((v as f32 / step).round() * step).clamp(0.0, 255.0) as u8
}

/// A color resolved against a specific [`ColorDepth`]'s displayable
/// palette, alongside enough information to both emit its ANSI escape and
/// (for error-diffusion dithering) know exactly what color will actually
/// appear on screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Quantized {
    TrueColor(Rgb8),
    Ansi256(u8),
    Ansi16(u8),
}

impl Quantized {
    pub(crate) fn rgb(&self) -> Rgb8 {
        match *self {
            Quantized::TrueColor(c) => c,
            Quantized::Ansi256(i) => ansi256_rgb(i),
            Quantized::Ansi16(i) => ansi16_rgb(i),
        }
    }

    pub(crate) fn push_escape(&self, buf: &mut Vec<u8>) {
        match *self {
            Quantized::TrueColor((r, g, b)) => fast_ansi::push_truecolor_escape(buf, r, g, b),
            Quantized::Ansi256(i) => fast_ansi::push_ansi256_escape(buf, i),
            Quantized::Ansi16(i) => fast_ansi::push_ansi16_escape(buf, i),
        }
    }
}

/// Resolves `c` against `depth`'s palette. `levels`, when `Some`, additionally
/// posterizes each channel before the palette lookup — meaningful only for
/// [`ColorDepth::TrueColor`] (used by [`crate::dither`] to posterize before
/// diffusing error; `Ansi256`/`Ansi16` are already coarse discrete palettes,
/// so `levels` is ignored for them).
pub(crate) fn quantize_for_depth(c: Rgb8, depth: ColorDepth, levels: Option<u8>) -> Quantized {
    match depth {
        ColorDepth::TrueColor => match levels {
            Some(l) => Quantized::TrueColor((
                posterize_channel(c.0, l),
                posterize_channel(c.1, l),
                posterize_channel(c.2, l),
            )),
            None => Quantized::TrueColor(c),
        },
        ColorDepth::Ansi256 => Quantized::Ansi256(nearest_ansi256(c)),
        ColorDepth::Ansi16 => Quantized::Ansi16(nearest_ansi16(c)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nearest_ansi16_matches_exact_palette_entries() {
        for (i, &c) in ANSI16_PALETTE.iter().enumerate() {
            assert_eq!(nearest_ansi16(c), i as u8);
        }
    }

    #[test]
    fn ansi256_roundtrips_cube_corners() {
        let idx = nearest_ansi256((255, 0, 0));
        let rgb = ansi256_rgb(idx);
        assert!(rgb.0 > 200 && rgb.1 < 50 && rgb.2 < 50);
    }

    #[test]
    fn posterize_channel_clamps_to_levels() {
        assert_eq!(posterize_channel(0, 2), 0);
        assert_eq!(posterize_channel(255, 2), 255);
        assert_eq!(posterize_channel(130, 2), 255);
        assert_eq!(posterize_channel(120, 2), 0);
    }

    #[test]
    fn quantize_truecolor_without_levels_is_identity() {
        let q = quantize_for_depth((10, 20, 30), ColorDepth::TrueColor, None);
        assert_eq!(q.rgb(), (10, 20, 30));
    }
}