use super::{Theme, ThemeColor};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorDepth {
TrueColor,
Ansi256,
Ansi16,
}
pub fn detect() -> ColorDepth {
static DEPTH: std::sync::OnceLock<ColorDepth> = std::sync::OnceLock::new();
*DEPTH.get_or_init(|| {
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>) -> ColorDepth {
if let Some(ct) = colorterm {
let ct = ct.to_ascii_lowercase();
if ct.contains("truecolor") || ct.contains("24bit") {
return ColorDepth::TrueColor;
}
}
if let Some(t) = term {
let t = t.to_ascii_lowercase();
if t.contains("direct") || t.contains("truecolor") {
return ColorDepth::TrueColor;
}
if t.contains("256color") {
return ColorDepth::Ansi256;
}
}
ColorDepth::Ansi16
}
impl Theme {
pub fn adapt_to_terminal(self) -> Theme {
self.adapt(detect())
}
pub fn adapt(self, depth: ColorDepth) -> Theme {
match depth {
ColorDepth::TrueColor => self,
ColorDepth::Ansi256 => self.into_quantized_256(),
ColorDepth::Ansi16 => self.into_ansi16(),
}
}
fn into_quantized_256(mut self) -> Theme {
for color in self.roles_mut() {
if let ThemeColor::Rgb(r, g, b) = *color {
*color = ThemeColor::Ansi(nearest_256(r, g, b));
}
}
self
}
fn into_ansi16(self) -> Theme {
Theme {
name: self.name,
..Theme::ansi()
}
}
fn roles_mut(&mut self) -> impl Iterator<Item = &mut ThemeColor> {
[
&mut self.bg,
&mut self.bg_hard,
&mut self.bg_soft,
&mut self.bg_panel,
&mut self.selection_bg,
&mut self.fg,
&mut self.fg_bright,
&mut self.fg_secondary,
&mut self.gray,
&mut self.selection_fg,
&mut self.border_dim,
&mut self.focus_border,
&mut self.accent,
&mut self.cursor,
&mut self.red,
&mut self.green,
&mut self.yellow,
&mut self.blue,
&mut self.purple,
&mut self.aqua,
&mut self.orange,
&mut self.color_directory,
&mut self.color_journal_date,
&mut self.color_search_match,
&mut self.color_tag,
&mut self.blockquote_bar,
&mut self.code_bg,
]
.into_iter()
}
}
fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
let cube_idx = |c: u8| -> u8 {
if c < 48 {
0
} else if c < 115 {
1
} else {
((c as u16 - 35) / 40).min(5) as u8
}
};
let level = |i: u8| -> u8 { if i == 0 { 0 } else { 55 + i * 40 } };
let (ci, cg, cb) = (cube_idx(r), cube_idx(g), cube_idx(b));
let cube = (16 + 36 * ci as u16 + 6 * cg as u16 + cb as u16) as u8;
let cube_rgb = (level(ci), level(cg), level(cb));
let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
let gi = if gray_avg < 8 {
0
} else {
(((gray_avg - 8) + 5) / 10).min(23)
};
let gray = (232 + gi) as u8;
let gl = (8 + 10 * gi) as u8;
let gray_rgb = (gl, gl, gl);
let dist = |(cr, cg2, cb2): (u8, u8, u8)| -> u32 {
let dr = r as i32 - cr as i32;
let dg = g as i32 - cg2 as i32;
let db = b as i32 - cb2 as i32;
(dr * dr + dg * dg + db * db) as u32
};
if dist(gray_rgb) < dist(cube_rgb) {
gray
} else {
cube
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_truecolor_from_colorterm() {
assert_eq!(
from_env(Some("truecolor"), Some("xterm-256color")),
ColorDepth::TrueColor
);
assert_eq!(from_env(Some("24bit"), None), ColorDepth::TrueColor);
}
#[test]
fn detects_truecolor_from_term_direct() {
assert_eq!(from_env(None, Some("xterm-direct")), ColorDepth::TrueColor);
}
#[test]
fn detects_256color_from_term() {
assert_eq!(from_env(None, Some("xterm-256color")), ColorDepth::Ansi256);
assert_eq!(
from_env(Some(""), Some("screen-256color")),
ColorDepth::Ansi256
);
}
#[test]
fn falls_back_to_ansi16() {
assert_eq!(from_env(None, Some("xterm")), ColorDepth::Ansi16);
assert_eq!(from_env(None, None), ColorDepth::Ansi16);
assert_eq!(from_env(Some("yes"), Some("vt100")), ColorDepth::Ansi16);
}
#[test]
fn nearest_256_known_values() {
assert_eq!(nearest_256(0, 0, 0), 16); assert_eq!(nearest_256(255, 255, 255), 231); assert_eq!(nearest_256(255, 0, 0), 196); assert_eq!(nearest_256(0, 255, 0), 46); assert_eq!(nearest_256(0, 0, 255), 21); let gray = nearest_256(128, 128, 128);
assert!((232..=255).contains(&gray), "got {}", gray);
}
#[test]
fn truecolor_adapt_is_identity() {
let theme = Theme::gruvbox_dark();
assert_eq!(theme.clone().adapt(ColorDepth::TrueColor), theme);
}
#[test]
fn ansi256_adapt_leaves_no_rgb() {
let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi256);
let mut theme = theme;
for color in theme.roles_mut() {
assert!(
!matches!(color, ThemeColor::Rgb(..)),
"RGB role survived 256-color adaptation: {}",
color
);
}
}
#[test]
fn ansi16_adapt_delegates_to_builtin_ansi_mapping() {
let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi16);
let expected = Theme {
name: "Gruvbox Dark".to_string(),
..Theme::ansi()
};
assert_eq!(theme, expected);
}
#[test]
fn ansi16_adapt_has_no_rgb_for_any_builtin() {
for theme in Theme::builtins() {
let name = theme.name.clone();
let mut adapted = theme.adapt(ColorDepth::Ansi16);
for color in adapted.roles_mut() {
assert!(
!matches!(color, ThemeColor::Rgb(..)),
"theme {:?}: RGB role survived 16-color adaptation",
name
);
}
}
}
}