use std::ffi::OsStr;
use std::sync::OnceLock;
use ratatui::buffer::Buffer;
use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColourDepth {
#[default]
TrueColour,
Ansi256,
}
static DEPTH: OnceLock<ColourDepth> = OnceLock::new();
impl ColourDepth {
pub fn for_environment(term_program: Option<&OsStr>, colorterm: Option<&OsStr>) -> Self {
let apple = term_program.is_some_and(|p| p == "Apple_Terminal");
let rgb = colorterm.is_some_and(|c| c == "truecolor" || c == "24bit");
if apple && !rgb {
ColourDepth::Ansi256
} else {
ColourDepth::TrueColour
}
}
pub fn current() -> Self {
DEPTH.get().copied().unwrap_or_default()
}
}
pub fn set(depth: ColourDepth) {
let _ = DEPTH.set(depth);
}
const CUBE: [u8; 6] = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
fn cube_level(v: u8) -> usize {
match v {
0..48 => 0,
48..115 => 1,
_ => usize::from((v - 35) / 40),
}
}
fn distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
let d = |x: u8, y: u8| (i32::from(x) - i32::from(y)).unsigned_abs().pow(2);
d(a.0, b.0) + d(a.1, b.1) + d(a.2, b.2)
}
pub fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
let (qr, qg, qb) = (cube_level(r), cube_level(g), cube_level(b));
let cube = (CUBE[qr], CUBE[qg], CUBE[qb]);
let cube_index = (16 + 36 * qr + 6 * qg + qb) as u8;
if cube == (r, g, b) {
return cube_index;
}
let average = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
let step = ((average.saturating_sub(3)) / 10).min(23) as u8;
let grey = 8 + 10 * step;
if distance((grey, grey, grey), (r, g, b)) < distance(cube, (r, g, b)) {
232 + step
} else {
cube_index
}
}
pub fn reduced(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Indexed(nearest_256(r, g, b)),
other => other,
}
}
pub fn reduce(buf: &mut Buffer) {
for cell in buf.content.iter_mut() {
cell.fg = reduced(cell.fg);
cell.bg = reduced(cell.bg);
cell.underline_color = reduced(cell.underline_color);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use ratatui::style::Style;
fn env(term_program: Option<&str>, colorterm: Option<&str>) -> ColourDepth {
ColourDepth::for_environment(term_program.map(OsStr::new), colorterm.map(OsStr::new))
}
#[test]
fn only_terminal_app_without_truecolor_gets_256() {
assert_eq!(env(Some("Apple_Terminal"), None), ColourDepth::Ansi256);
assert_eq!(env(Some("Apple_Terminal"), Some("")), ColourDepth::Ansi256);
assert_eq!(
env(Some("Apple_Terminal"), Some("truecolor")),
ColourDepth::TrueColour
);
assert_eq!(
env(Some("Apple_Terminal"), Some("24bit")),
ColourDepth::TrueColour
);
assert_eq!(
env(Some("iTerm.app"), Some("truecolor")),
ColourDepth::TrueColour
);
assert_eq!(env(Some("ghostty"), None), ColourDepth::TrueColour);
assert_eq!(env(None, None), ColourDepth::TrueColour);
}
#[test]
fn a_cube_colour_is_itself() {
assert_eq!(nearest_256(0, 0, 0), 16);
assert_eq!(nearest_256(0xff, 0xff, 0xff), 231);
assert_eq!(nearest_256(0xff, 0, 0), 196);
assert_eq!(nearest_256(0x5f, 0x87, 0xaf), 16 + 36 + 12 + 3);
}
#[test]
fn a_near_grey_goes_to_the_grey_ramp() {
assert_eq!(nearest_256(15, 17, 21), 233);
assert_eq!(nearest_256(8, 8, 8), 232);
assert_eq!(nearest_256(238, 238, 238), 255);
assert_eq!(
nearest_256(250, 250, 250),
231,
"past the last grey, white wins"
);
}
#[test]
fn a_colour_goes_to_the_nearest_cube_corner() {
assert_eq!(nearest_256(230, 140, 229), 16 + 36 * 4 + 6 * 2 + 4);
for v in [0u8, 47, 48, 114, 115, 154, 155, 194, 195, 234, 235, 255] {
let level = cube_level(v);
let below = level.checked_sub(1).map(|l| CUBE[l]);
let above = CUBE.get(level + 1).copied();
let here = i32::from(CUBE[level]);
for other in [below, above].into_iter().flatten() {
assert!(
(i32::from(v) - here).abs() <= (i32::from(v) - i32::from(other)).abs(),
"{v} -> level {level}"
);
}
}
}
#[test]
fn only_rgb_is_reduced() {
assert_eq!(reduced(Color::Rgb(0xff, 0, 0)), Color::Indexed(196));
assert_eq!(reduced(Color::Reset), Color::Reset);
assert_eq!(reduced(Color::Blue), Color::Blue);
assert_eq!(reduced(Color::Indexed(42)), Color::Indexed(42));
}
#[test]
fn a_frame_keeps_no_rgb() {
let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
buf.set_string(
0,
0,
"a",
Style::new()
.fg(Color::Rgb(15, 17, 21))
.bg(Color::Rgb(0xff, 0, 0)),
);
buf.set_string(1, 0, "b", Style::new().underline_color(Color::Rgb(0, 0, 0)));
buf.set_string(2, 0, "c", Style::new().fg(Color::Green));
reduce(&mut buf);
let cell = |x: u16| buf[(x, 0)].clone();
assert_eq!(
(cell(0).fg, cell(0).bg),
(Color::Indexed(233), Color::Indexed(196))
);
assert_eq!(cell(1).underline_color, Color::Indexed(16));
assert_eq!(
cell(2).fg,
Color::Green,
"a named colour is the terminal's own"
);
}
}