redbear-tui-theme 0.1.0

Default TUI skin palette for Red Bear OS — single source of truth for the Red Bear brand colours used by every TUI application
Documentation
//! Terminal-fallback colour resolution.
//!
//! For terminals that do not support truecolor (24-bit RGB), this
//! module finds the closest XTerm-256 or basic-16 colour for a given
//! [`Rgb`].

use crate::Rgb;

/// The 256-entry XTerm-256 palette. Indices 0..15 = the 16 ANSI
/// colours, 16..231 = the 6×6×6 colour cube, 232..255 = the
/// 24-step grayscale ramp.
const XTERM_256: [(u8, u8, u8); 256] = build_xterm_256();

const ANSI16_NAMES: [&str; 16] = [
    "Black", "Red", "Green", "Yellow",
    "Blue", "Magenta", "Cyan", "White",
    "Bright Black", "Bright Red", "Bright Green", "Bright Yellow",
    "Bright Blue", "Bright Magenta", "Bright Cyan", "Bright White",
];

const fn build_xterm_256() -> [(u8, u8, u8); 256] {
    let mut p = [(0u8, 0u8, 0u8); 256];
    // 0..15: standard ANSI 16
    p[0]  = (0,   0,   0);
    p[1]  = (128, 0,   0);
    p[2]  = (0,   128, 0);
    p[3]  = (128, 128, 0);
    p[4]  = (0,   0,   128);
    p[5]  = (128, 0,   128);
    p[6]  = (0,   128, 128);
    p[7]  = (192, 192, 192);
    p[8]  = (128, 128, 128);
    p[9]  = (255, 0,   0);
    p[10] = (0,   255, 0);
    p[11] = (255, 255, 0);
    p[12] = (0,   0,   255);
    p[13] = (255, 0,   255);
    p[14] = (0,   255, 255);
    p[15] = (255, 255, 255);
    // 16..231: 6×6×6 colour cube
    let mut i = 16usize;
    let levels = [0u8, 95, 135, 175, 215, 255];
    let mut r = 0;
    while r < 6 {
        let mut g = 0;
        while g < 6 {
            let mut b = 0;
            while b < 6 {
                p[i] = (levels[r], levels[g], levels[b]);
                i += 1;
                b += 1;
            }
            g += 1;
        }
        r += 1;
    }
    // 232..255: grayscale ramp
    let mut j = 232usize;
    let mut k = 8u8;
    while j < 256 {
        p[j] = (k, k, k);
        j += 1;
        k = k.saturating_add(10);
    }
    p
}

/// Find the closest XTerm-256 color index (0..=255) for an RGB
/// triple. Uses weighted Euclidean distance in sRGB space: pure
/// RGB distance plus a small luminance penalty to keep the
/// returned colour visually similar to the input.
#[must_use]
pub fn fallback_256(rgb: Rgb) -> u8 {
    let tl = linear_luma(rgb);
    let mut best_idx = 0u8;
    let mut best_d = f32::INFINITY;
    let mut i = 0u16;
    while i < 256 {
        let (r, g, b) = XTERM_256[i as usize];
        let dr = f32::from(rgb.0) - f32::from(r);
        let dg = f32::from(rgb.1) - f32::from(g);
        let db = f32::from(rgb.2) - f32::from(b);
        let il = linear_luma(Rgb(r, g, b));
        let d = dr * dr + dg * dg + db * db + 0.5 * (tl - il).powi(2);
        if d < best_d {
            best_d = d;
            best_idx = i as u8;
        }
        i += 1;
    }
    best_idx
}

/// Find the closest basic ANSI 16 colour **name** for an RGB triple.
/// Returns one of "Black", "Red", ..., "Bright White".
#[must_use]
pub fn fallback_16_name(rgb: Rgb) -> &'static str {
    let mut best = 0usize;
    let mut best_d = f32::INFINITY;
    let mut i = 0usize;
    while i < 16 {
        let (r, g, b) = XTERM_256[i];
        let dr = f32::from(rgb.0) - f32::from(r);
        let dg = f32::from(rgb.1) - f32::from(g);
        let db = f32::from(rgb.2) - f32::from(b);
        let d = dr * dr + dg * dg + db * db;
        if d < best_d {
            best_d = d;
            best = i;
        }
        i += 1;
    }
    ANSI16_NAMES[best]
}

/// sRGB-relative luminance per WCAG 2.1.
fn linear_luma(rgb: Rgb) -> f32 {
    fn lin(byte: u8) -> f32 {
        let c = f32::from(byte) / 255.0;
        if c <= 0.03928 {
            c / 12.92
        } else {
            ((c + 0.055) / 1.055).powf(2.4)
        }
    }
    0.2126 * lin(rgb.0) + 0.7152 * lin(rgb.1) + 0.0722 * lin(rgb.2)
}

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

    #[test]
    fn black_maps_to_index_0() {
        assert_eq!(fallback_256(Rgb(0, 0, 0)), 0);
    }

    #[test]
    fn white_maps_to_index_15() {
        assert_eq!(fallback_256(Rgb(255, 255, 255)), 15);
    }

    #[test]
    fn brand_red_near_256_red() {
        // #B52430 — closest 256 is index 125 (a magenta-red).
        // The match will be approximate on 256-colour terminals.
        let idx = fallback_256(Rgb(0xB5, 0x24, 0x30));
        assert!(idx == 125 || idx == 9, "got {idx}, expected ~125 (cube red) or 9 (ANSI bright red)");
    }

    #[test]
    fn ansi_name_for_black() {
        assert_eq!(fallback_16_name(Rgb(0, 0, 0)), "Black");
    }

    #[test]
    fn ansi_name_for_brand_red_is_red() {
        let name = fallback_16_name(Rgb(0xB5, 0x24, 0x30));
        assert!(name.contains("Red"), "expected a Red-ish name, got {name}");
    }
}