use std::collections::HashMap;
use ratatui::{buffer::Buffer, style::Color};
use crate::tui::theme::OverlayPalette;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorDepth {
TrueColor,
Indexed256,
}
impl ColorDepth {
pub fn detect() -> Self {
Self::from_env(
std::env::var("COLORTERM").ok().as_deref(),
std::env::var("TERM").ok().as_deref(),
)
}
fn from_env(colorterm: Option<&str>, term: Option<&str>) -> Self {
let colorterm = colorterm.unwrap_or_default();
let direct_term = term.is_some_and(|term| term.ends_with("-direct"));
if colorterm.eq_ignore_ascii_case("truecolor")
|| colorterm.eq_ignore_ascii_case("24bit")
|| direct_term
{
ColorDepth::TrueColor
} else {
ColorDepth::Indexed256
}
}
pub fn fit(self, buffer: &mut Buffer, palette: &OverlayPalette) {
if self == ColorDepth::TrueColor {
return;
}
let mut fitted: HashMap<Color, Color> = fit_palette(palette).into_iter().collect();
let mut fit = |color: Color| *fitted.entry(color).or_insert_with(|| to_indexed(color));
for cell in &mut buffer.content {
cell.fg = fit(cell.fg);
cell.bg = fit(cell.bg);
cell.underline_color = fit(cell.underline_color);
}
}
}
fn fit_palette(palette: &OverlayPalette) -> Vec<(Color, Color)> {
let roles = [
palette.insert_bg,
palette.delete_bg,
palette.update_bg,
palette.move_bg,
palette.cross_highlight_bg,
palette.search_bg,
];
let mut fitted: Vec<(Color, Color)> = Vec::with_capacity(roles.len());
for color in roles {
let Color::Rgb(r, g, b) = color else {
continue;
};
if fitted.iter().any(|(from, _)| *from == color) {
continue;
}
let taken = |index: u8| fitted.iter().any(|(_, to)| *to == Color::Indexed(index));
let nearest = candidates((r, g, b));
let index = nearest
.iter()
.copied()
.find(|&index| !taken(index))
.unwrap_or(nearest[0]);
fitted.push((color, Color::Indexed(index)));
}
fitted
}
const GREY_SPREAD: u8 = 13;
fn to_indexed(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Indexed(candidates((r, g, b))[0]),
other => other,
}
}
fn candidates((r, g, b): (u8, u8, u8)) -> Vec<u8> {
let spread = r.max(g).max(b) - r.min(g).min(b);
if spread < GREY_SPREAD {
return vec![grey_index(r, g, b)];
}
let mut tinted: Vec<(f32, u8)> = (16..=231u8)
.filter(|&index| {
let (r, g, b) = xterm_256(index);
!(r == g && g == b)
})
.map(|index| (hue_weighted_distance((r, g, b), xterm_256(index)), index))
.collect();
tinted.sort_by(|x, y| x.0.total_cmp(&y.0));
tinted.into_iter().map(|(_, index)| index).collect()
}
const LIGHTNESS_WEIGHT: f32 = 1.0;
const CHROMA_WEIGHT: f32 = 0.25;
const HUE_WEIGHT: f32 = 2.0;
fn hue_weighted_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 {
let split = |(r, g, b): (u8, u8, u8)| {
let rgb = [r as f32, g as f32, b as f32];
let mean = rgb.iter().sum::<f32>() / 3.0;
(mean, rgb.map(|channel| channel - mean))
};
let length = |c: [f32; 3]| c.iter().map(|x| x * x).sum::<f32>().sqrt();
let ((mean_a, chroma_a), (mean_b, chroma_b)) = (split(a), split(b));
let (length_a, length_b) = (length(chroma_a), length(chroma_b));
let dot: f32 = (0..3).map(|i| chroma_a[i] * chroma_b[i]).sum();
let hue = 2.0 * (length_a * length_b - dot);
LIGHTNESS_WEIGHT * (mean_a - mean_b).powi(2)
+ CHROMA_WEIGHT * (length_a - length_b).powi(2)
+ HUE_WEIGHT * hue
}
pub fn xterm_256(index: u8) -> (u8, u8, u8) {
const NAMED: [Color; 16] = [
Color::Black,
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
Color::Gray,
Color::DarkGray,
Color::LightRed,
Color::LightGreen,
Color::LightYellow,
Color::LightBlue,
Color::LightMagenta,
Color::LightCyan,
Color::White,
];
match index {
0..=15 => {
let hex = crate::tui::theme::format_hex_color(NAMED[index as usize]);
let channel = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).unwrap_or(0);
(channel(1), channel(3), channel(5))
}
16..=231 => {
let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
let cube = index - 16;
(level(cube / 36), level(cube / 6 % 6), level(cube % 6))
}
232..=255 => {
let grey = 8 + 10 * (index - 232);
(grey, grey, grey)
}
}
}
fn grey_index(r: u8, g: u8, b: u8) -> u8 {
let average = (r as u16 + g as u16 + b as u16) / 3;
match average {
0..=3 => 16,
247.. => 231,
_ => 232 + ((average - 3) / 10).min(23) as u8,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::theme::OverlayTheme;
use strum::IntoEnumIterator;
#[test]
fn only_an_advertised_24_bit_terminal_gets_true_color() {
assert_eq!(
ColorDepth::from_env(Some("truecolor"), Some("xterm-256color")),
ColorDepth::TrueColor
);
assert_eq!(
ColorDepth::from_env(Some("24bit"), None),
ColorDepth::TrueColor
);
assert_eq!(
ColorDepth::from_env(None, Some("xterm-direct")),
ColorDepth::TrueColor
);
assert_eq!(
ColorDepth::from_env(None, Some("xterm-256color")),
ColorDepth::Indexed256
);
assert_eq!(ColorDepth::from_env(None, None), ColorDepth::Indexed256);
}
#[test]
fn a_pure_color_maps_to_its_cube_corner() {
assert_eq!(to_indexed(Color::Rgb(255, 0, 0)), Color::Indexed(196));
assert_eq!(to_indexed(Color::Rgb(0, 255, 0)), Color::Indexed(46));
assert_eq!(to_indexed(Color::Rgb(0, 0, 255)), Color::Indexed(21));
}
#[test]
fn greys_use_the_ramp_and_its_ends_the_cube() {
assert_eq!(to_indexed(Color::Rgb(0, 0, 0)), Color::Indexed(16));
assert_eq!(to_indexed(Color::Rgb(255, 255, 255)), Color::Indexed(231));
assert_eq!(to_indexed(Color::Rgb(8, 8, 8)), Color::Indexed(232));
assert_eq!(to_indexed(Color::Rgb(48, 48, 48)), Color::Indexed(236));
assert_eq!(to_indexed(Color::Rgb(238, 238, 238)), Color::Indexed(255));
}
#[test]
fn a_dark_tinted_band_keeps_its_hue() {
assert_eq!(to_indexed(Color::Rgb(20, 60, 20)), Color::Indexed(22));
assert_eq!(to_indexed(Color::Rgb(70, 20, 20)), Color::Indexed(52));
}
#[test]
fn named_and_indexed_colors_pass_through() {
for color in [Color::Reset, Color::Red, Color::Indexed(3)] {
assert_eq!(to_indexed(color), color);
}
}
#[test]
fn every_presets_bands_cursor_and_search_stay_distinct_in_256_colors() {
for theme in OverlayTheme::iter() {
let fitted = fit_palette(&theme.palette());
assert_eq!(fitted.len(), 6, "{theme:?}: two roles share an rgb color");
for (i, (from_a, a)) in fitted.iter().enumerate() {
for (from_b, b) in &fitted[i + 1..] {
assert_ne!(
a, b,
"{theme:?}: {from_a:?} and {from_b:?} both became {a:?}"
);
}
}
}
}
#[test]
fn a_colliding_band_takes_the_next_nearest_tinted_color() {
let palette = OverlayPalette {
insert_bg: Color::Rgb(20, 60, 20),
update_bg: Color::Rgb(21, 60, 20),
..OverlayTheme::Dark.palette()
};
assert_eq!(to_indexed(palette.insert_bg), to_indexed(palette.update_bg));
let fitted = fit_palette(&palette);
let (insert, update) = (fitted[0].1, fitted[2].1);
assert_eq!(insert, to_indexed(palette.insert_bg));
assert_ne!(update, insert);
let Color::Indexed(update) = update else {
panic!("expected an index, got {update:?}");
};
let (r, g, b) = xterm_256(update);
assert!(!(r == g && g == b), "a tinted band fell back to a grey");
}
#[test]
fn fitting_a_frame_rewrites_every_rgb_cell() {
let mut buffer = Buffer::empty(ratatui::layout::Rect::new(0, 0, 2, 1));
buffer.content[0].set_bg(Color::Rgb(255, 0, 0));
buffer.content[1].set_fg(Color::Rgb(0, 0, 255));
let palette = OverlayTheme::default().palette();
ColorDepth::TrueColor.fit(&mut buffer, &palette);
assert_eq!(buffer.content[0].bg, Color::Rgb(255, 0, 0));
ColorDepth::Indexed256.fit(&mut buffer, &palette);
assert_eq!(buffer.content[0].bg, Color::Indexed(196));
assert_eq!(buffer.content[1].fg, Color::Indexed(21));
}
}