use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Depth {
Truecolor,
Ansi256,
None,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Choice {
#[default]
Auto,
Truecolor,
Ansi256,
Never,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Env {
pub colorterm: Option<String>,
pub term: Option<String>,
pub no_color: Option<String>,
}
impl Env {
pub fn from_process() -> Self {
let var = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
Env {
colorterm: var("COLORTERM"),
term: var("TERM"),
no_color: var("NO_COLOR"),
}
}
}
pub fn resolve(choice: Choice, env: &Env) -> Depth {
match choice {
Choice::Truecolor => Depth::Truecolor,
Choice::Ansi256 => Depth::Ansi256,
Choice::Never => Depth::None,
Choice::Auto => detect(env),
}
}
pub fn enforce(choice: Choice, depth: Depth) {
if choice != Choice::Auto {
crossterm::style::force_color_output(depth != Depth::None);
}
}
pub fn detect(env: &Env) -> Depth {
if env.no_color.is_some() {
return Depth::None;
}
let term = env.term.as_deref();
if term == Some("dumb") {
return Depth::None;
}
let claims_truecolor = |value: &str| {
value.eq_ignore_ascii_case("truecolor") || value.eq_ignore_ascii_case("24bit")
};
if env.colorterm.as_deref().is_some_and(claims_truecolor) {
return Depth::Truecolor;
}
match term {
Some(term) if term.contains("truecolor") || term.contains("24bit") => Depth::Truecolor,
Some(_) => Depth::Ansi256,
None => Depth::Truecolor,
}
}
pub fn degrade(buffer: &mut Buffer, depth: Depth) {
match depth {
Depth::Truecolor => {}
Depth::Ansi256 => {
for cell in &mut buffer.content {
cell.fg = indexed(cell.fg);
cell.bg = indexed(cell.bg);
}
}
Depth::None => {
for cell in &mut buffer.content {
cell.fg = Color::Reset;
cell.bg = Color::Reset;
}
}
}
}
fn indexed(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Indexed(quantize(r, g, b)),
other => other,
}
}
const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
pub fn quantize(r: u8, g: u8, b: u8) -> u8 {
let level = |channel: u8| -> usize {
CUBE_LEVELS
.iter()
.enumerate()
.min_by_key(|(_, &level)| (level as i32 - channel as i32).abs())
.map(|(index, _)| index)
.unwrap_or(0)
};
let (ri, gi, bi) = (level(r), level(g), level(b));
let cube_index = 16 + 36 * ri + 6 * gi + bi;
let cube_distance = distance(
(r, g, b),
(CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]),
);
let average = (r as u32 + g as u32 + b as u32) / 3;
let step = (average.saturating_sub(3) / 10).min(23);
let grey = (8 + 10 * step) as u8;
let grey_distance = distance((r, g, b), (grey, grey, grey));
if grey_distance < cube_distance {
(232 + step) as u8
} else {
cube_index as u8
}
}
fn distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
let channel = |x: u8, y: u8| {
let d = x as i32 - y as i32;
(d * d) as u32
};
channel(a.0, b.0) + channel(a.1, b.1) + channel(a.2, b.2)
}
pub fn highlight(color: Color) -> ratatui::style::Style {
ratatui::style::Style::default()
.fg(color)
.bg(crate::theme::base())
.add_modifier(Modifier::REVERSED)
}
#[cfg(test)]
mod tests {
use super::*;
fn env(colorterm: Option<&str>, term: Option<&str>) -> Env {
Env {
colorterm: colorterm.map(str::to_string),
term: term.map(str::to_string),
no_color: None,
}
}
fn palette_rgb(index: u8) -> (u8, u8, u8) {
if index >= 232 {
let grey = 8 + 10 * (index as u16 - 232);
(grey as u8, grey as u8, grey as u8)
} else {
let n = index as usize - 16;
(
CUBE_LEVELS[n / 36],
CUBE_LEVELS[(n / 6) % 6],
CUBE_LEVELS[n % 6],
)
}
}
#[test]
fn every_palette_colour_quantizes_to_itself() {
for index in 16..=255u8 {
let (r, g, b) = palette_rgb(index);
assert_eq!(
quantize(r, g, b),
index,
"index {index} ({r}, {g}, {b}) should be its own nearest match"
);
}
}
#[test]
fn greys_prefer_the_ramp_over_the_cube_diagonal() {
let index = quantize(120, 120, 120);
assert!(index >= 232, "expected a greyscale index, got {index}");
assert_eq!(palette_rgb(index), (118, 118, 118));
}
#[test]
fn a_saturated_colour_takes_the_cube() {
let index = quantize(255, 0, 0);
assert_eq!(index, 196);
assert_eq!(palette_rgb(index), (255, 0, 0));
}
#[test]
fn colorterm_is_the_one_claim_of_truecolor_we_take_at_its_word() {
assert_eq!(
detect(&env(Some("truecolor"), Some("xterm"))),
Depth::Truecolor
);
assert_eq!(detect(&env(Some("24bit"), Some("xterm"))), Depth::Truecolor);
assert_eq!(detect(&env(Some("TrueColor"), None)), Depth::Truecolor);
}
#[test]
fn a_256_colour_term_without_colorterm_gets_the_palette() {
assert_eq!(detect(&env(None, Some("xterm-256color"))), Depth::Ansi256);
assert_eq!(detect(&env(None, Some("screen-256color"))), Depth::Ansi256);
assert_eq!(
detect(&env(Some("8bit"), Some("xterm-256color"))),
Depth::Ansi256
);
}
#[test]
fn a_term_we_have_never_heard_of_gets_the_conservative_answer() {
assert_eq!(detect(&env(None, Some("vt220"))), Depth::Ansi256);
assert_eq!(detect(&env(None, Some("linux"))), Depth::Ansi256);
}
#[test]
fn a_terminal_that_names_itself_truecolor_is_believed_too() {
assert_eq!(
detect(&env(None, Some("xterm-truecolor"))),
Depth::Truecolor
);
}
#[test]
fn no_term_at_all_is_windows_and_keeps_its_colour() {
assert_eq!(detect(&env(None, None)), Depth::Truecolor);
}
#[test]
fn a_dumb_terminal_gets_no_colour() {
assert_eq!(detect(&env(Some("truecolor"), Some("dumb"))), Depth::None);
}
#[test]
fn no_color_wins_over_anything_the_terminal_advertises() {
let mut env = env(Some("truecolor"), Some("xterm-256color"));
env.no_color = Some("1".to_string());
assert_eq!(detect(&env), Depth::None);
env.no_color = Some("0".to_string());
assert_eq!(detect(&env), Depth::None);
}
#[test]
fn an_empty_variable_is_the_same_as_an_unset_one() {
assert_eq!(detect(&Env::default()), Depth::Truecolor);
}
#[test]
fn the_flag_outranks_both_the_terminal_and_no_color() {
let mut hostile = env(None, Some("dumb"));
hostile.no_color = Some("1".to_string());
assert_eq!(resolve(Choice::Truecolor, &hostile), Depth::Truecolor);
assert_eq!(resolve(Choice::Ansi256, &hostile), Depth::Ansi256);
assert_eq!(
resolve(Choice::Never, &env(Some("truecolor"), None)),
Depth::None
);
assert_eq!(resolve(Choice::Auto, &hostile), Depth::None);
}
fn painted(color: Color) -> Buffer {
let mut buffer = Buffer::empty(ratatui::layout::Rect::new(0, 0, 1, 1));
buffer.content[0].fg = color;
buffer.content[0].bg = color;
buffer
}
#[test]
fn truecolor_leaves_the_frame_exactly_as_rendered() {
let mut buffer = painted(Color::Rgb(1, 2, 3));
degrade(&mut buffer, Depth::Truecolor);
assert_eq!(buffer.content[0].fg, Color::Rgb(1, 2, 3));
}
#[test]
fn the_palette_pass_rewrites_both_halves_of_a_half_block_cell() {
let mut buffer = painted(Color::Rgb(255, 0, 0));
degrade(&mut buffer, Depth::Ansi256);
assert_eq!(buffer.content[0].fg, Color::Indexed(196));
assert_eq!(buffer.content[0].bg, Color::Indexed(196));
}
#[test]
fn a_colour_the_terminal_already_defines_is_passed_through_untouched() {
let mut buffer = painted(Color::Indexed(42));
degrade(&mut buffer, Depth::Ansi256);
assert_eq!(buffer.content[0].fg, Color::Indexed(42));
let mut buffer = painted(Color::Reset);
degrade(&mut buffer, Depth::Ansi256);
assert_eq!(buffer.content[0].fg, Color::Reset);
}
#[test]
fn no_colour_leaves_the_modifiers_that_carry_meaning_without_it() {
let mut buffer = painted(Color::Rgb(255, 236, 39));
buffer.content[0].modifier = Modifier::REVERSED;
degrade(&mut buffer, Depth::None);
assert_eq!(buffer.content[0].fg, Color::Reset);
assert_eq!(buffer.content[0].bg, Color::Reset);
assert_eq!(buffer.content[0].modifier, Modifier::REVERSED);
}
}