use ratatui::style::{Color, Style};
use zeph_config::ColorMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveColorMode {
Truecolor,
Ansi256,
Ansi16,
Never,
}
#[must_use]
pub fn resolve_color_mode(mode: ColorMode) -> EffectiveColorMode {
match mode {
ColorMode::Truecolor => EffectiveColorMode::Truecolor,
ColorMode::Ansi256 => EffectiveColorMode::Ansi256,
ColorMode::Ansi16 => EffectiveColorMode::Ansi16,
ColorMode::Never => EffectiveColorMode::Never,
_ => detect(),
}
}
#[must_use]
pub fn detect_unicode_capable() -> bool {
if std::env::var("TERM").as_deref() == Ok("dumb") {
return false;
}
for var in ["LC_ALL", "LANG"] {
if let Ok(val) = std::env::var(var) {
let upper = val.to_uppercase();
if upper.contains("UTF-8") || upper.contains("UTF8") {
return true;
}
}
}
true
}
fn detect() -> EffectiveColorMode {
if std::env::var_os("NO_COLOR").is_some() {
return EffectiveColorMode::Never;
}
if let Ok(colorterm) = std::env::var("COLORTERM")
&& (colorterm == "truecolor" || colorterm == "24bit")
{
return EffectiveColorMode::Truecolor;
}
if let Ok(term) = std::env::var("TERM") {
if term == "dumb" {
return EffectiveColorMode::Never;
}
if term.contains("256color") {
return EffectiveColorMode::Ansi256;
}
let base = term.split('-').next().unwrap_or("");
if matches!(
base,
"xterm" | "screen" | "vt100" | "linux" | "rxvt" | "konsole"
) {
return EffectiveColorMode::Ansi16;
}
} else {
return EffectiveColorMode::Never;
}
EffectiveColorMode::Ansi256
}
#[must_use]
pub fn map_color(color: Color, mode: EffectiveColorMode) -> Color {
match mode {
EffectiveColorMode::Truecolor => color,
EffectiveColorMode::Never => Color::Reset,
EffectiveColorMode::Ansi256 => {
if let Color::Rgb(r, g, b) = color {
Color::Indexed(rgb_to_ansi256(r, g, b))
} else {
color
}
}
EffectiveColorMode::Ansi16 => {
if let Color::Rgb(r, g, b) = color {
Color::Indexed(rgb_to_ansi16(r, g, b))
} else {
color
}
}
}
}
#[must_use]
pub fn apply_mode(style: Style, mode: EffectiveColorMode) -> Style {
match mode {
EffectiveColorMode::Truecolor => style,
EffectiveColorMode::Never => Style {
fg: None,
bg: None,
underline_color: None,
add_modifier: style.add_modifier,
sub_modifier: style.sub_modifier,
},
EffectiveColorMode::Ansi256 | EffectiveColorMode::Ansi16 => {
let mut s = style;
if let Some(fg) = s.fg {
s.fg = Some(map_color(fg, mode));
}
if let Some(bg) = s.bg {
s.bg = Some(map_color(bg, mode));
}
if let Some(ul) = s.underline_color {
s.underline_color = Some(map_color(ul, mode));
}
s
}
}
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
let (cube_idx, cube_dist) = nearest_cube(r, g, b);
let (gray_idx, gray_dist) = nearest_gray_ramp(r, g, b);
if gray_dist <= cube_dist {
gray_idx
} else {
cube_idx
}
}
fn quantize_cube_level(v: u8) -> (u8, u8) {
const LEVELS: [(u8, u8); 6] = [(0, 0), (1, 95), (2, 135), (3, 175), (4, 215), (5, 255)];
const LEVEL_VALS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let vi = i16::from(v);
let mut best_idx = 0u8;
let mut best_dist = i32::MAX;
for (idx, level) in LEVELS {
let d = i32::from((vi - i16::from(level)).abs());
if d < best_dist {
best_dist = d;
best_idx = idx;
}
}
(best_idx, LEVEL_VALS[best_idx as usize])
}
fn nearest_cube(r: u8, g: u8, b: u8) -> (u8, u32) {
let (ri, rv) = quantize_cube_level(r);
let (gi, gv) = quantize_cube_level(g);
let (bi, bv) = quantize_cube_level(b);
let idx = 16 + 36 * ri + 6 * gi + bi;
let dist = dist_sq(r, g, b, rv, gv, bv);
(idx, dist)
}
fn nearest_gray_ramp(r: u8, g: u8, b: u8) -> (u8, u32) {
let luma = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000;
let n = if luma < 8 {
0u8
} else if luma >= 238 {
23u8
} else {
u8::try_from((luma - 8 + 5) / 10).unwrap_or(23)
};
let n = n.min(23);
let gray_val = 8 + 10 * n;
let idx = 232 + n;
let dist = dist_sq(r, g, b, gray_val, gray_val, gray_val);
(idx, dist)
}
fn dist_sq(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
let dr = u32::from(r1.abs_diff(r2)).pow(2);
let dg = u32::from(g1.abs_diff(g2)).pow(2);
let db = u32::from(b1.abs_diff(b2)).pow(2);
dr + dg + db
}
const ANSI16_PALETTE: [(u8, u8, u8); 16] = [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ];
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> u8 {
let mut best_idx = 0u8;
let mut best_dist = u32::MAX;
for (i, &(pr, pg, pb)) in ANSI16_PALETTE.iter().enumerate() {
let d = dist_sq(r, g, b, pr, pg, pb);
if d < best_dist {
best_dist = d;
#[allow(clippy::cast_possible_truncation)]
{
best_idx = i as u8;
} }
}
best_idx
}
#[cfg(test)]
mod tests {
use ratatui::style::Modifier;
use super::*;
#[test]
fn no_color_strips_all_colors() {
let style = Style::default()
.fg(Color::Rgb(255, 0, 0))
.bg(Color::Rgb(0, 0, 0))
.add_modifier(Modifier::BOLD);
let out = apply_mode(style, EffectiveColorMode::Never);
assert_eq!(out.fg, None);
assert_eq!(out.bg, None);
assert!(out.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn truecolor_identity() {
let style = Style::default().fg(Color::Rgb(31, 185, 168));
assert_eq!(apply_mode(style, EffectiveColorMode::Truecolor), style);
}
#[test]
fn ansi256_black_maps_to_index_16() {
let idx = rgb_to_ansi256(0, 0, 0);
assert_eq!(idx, 16, "black should map to cube index 16");
}
#[test]
fn ansi256_near_gray_picks_ramp() {
let idx = rgb_to_ansi256(128, 128, 128);
assert!(
idx >= 232,
"near-gray (128,128,128) should prefer gray ramp, got {idx}"
);
}
#[test]
fn ansi256_color_downgrade() {
let idx = rgb_to_ansi256(31, 185, 168);
let _ = idx;
}
#[test]
fn ansi16_pure_red() {
let idx = rgb_to_ansi16(255, 0, 0);
assert!(
idx == 1 || idx == 9,
"pure red should map to red or bright red, got {idx}"
);
}
#[test]
fn map_color_never_resets() {
assert_eq!(
map_color(Color::Rgb(255, 128, 0), EffectiveColorMode::Never),
Color::Reset
);
}
#[test]
fn resolve_color_mode_passthrough() {
assert_eq!(
resolve_color_mode(ColorMode::Truecolor),
EffectiveColorMode::Truecolor
);
assert_eq!(
resolve_color_mode(ColorMode::Ansi256),
EffectiveColorMode::Ansi256
);
assert_eq!(
resolve_color_mode(ColorMode::Ansi16),
EffectiveColorMode::Ansi16
);
assert_eq!(
resolve_color_mode(ColorMode::Never),
EffectiveColorMode::Never
);
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn auto_with_no_color_env_resolves_to_never() {
unsafe { std::env::set_var("NO_COLOR", "1") };
let result = resolve_color_mode(ColorMode::Auto);
unsafe { std::env::remove_var("NO_COLOR") };
assert_eq!(result, EffectiveColorMode::Never);
}
#[allow(unsafe_code)]
unsafe fn clear_unicode_env() {
unsafe {
std::env::remove_var("TERM");
std::env::remove_var("LANG");
std::env::remove_var("LC_ALL");
}
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn unicode_capable_term_dumb_returns_false_even_with_utf8_lang() {
unsafe {
clear_unicode_env();
std::env::set_var("TERM", "dumb");
std::env::set_var("LANG", "en_US.UTF-8");
}
let result = detect_unicode_capable();
unsafe { clear_unicode_env() };
assert!(!result, "TERM=dumb must return false regardless of LANG");
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn unicode_capable_lang_utf8_returns_true() {
unsafe {
clear_unicode_env();
std::env::set_var("TERM", "xterm-256color");
std::env::set_var("LANG", "en_US.UTF-8");
}
let result = detect_unicode_capable();
unsafe { clear_unicode_env() };
assert!(result, "LANG containing UTF-8 must return true");
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn unicode_capable_lc_all_utf8_returns_true() {
unsafe {
clear_unicode_env();
std::env::set_var("TERM", "xterm-256color");
std::env::set_var("LC_ALL", "C.UTF-8");
}
let result = detect_unicode_capable();
unsafe { clear_unicode_env() };
assert!(result, "LC_ALL containing UTF-8 must return true");
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn unicode_capable_lowercase_utf8_is_still_detected() {
unsafe {
clear_unicode_env();
std::env::set_var("TERM", "xterm-256color");
std::env::set_var("LANG", "en_US.utf-8");
}
let result = detect_unicode_capable();
unsafe { clear_unicode_env() };
assert!(result, "lowercase utf-8 in LANG must still be detected");
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn unicode_capable_unset_env_defaults_to_true() {
unsafe { clear_unicode_env() };
let result = detect_unicode_capable();
unsafe { clear_unicode_env() };
assert!(result, "unset environment must default to Unicode-capable");
}
#[test]
#[serial_test::serial]
#[allow(unsafe_code)]
fn unicode_capable_non_utf8_lang_without_dumb_defaults_to_true() {
unsafe {
clear_unicode_env();
std::env::set_var("TERM", "xterm");
std::env::set_var("LANG", "C");
}
let result = detect_unicode_capable();
unsafe { clear_unicode_env() };
assert!(
result,
"non-UTF-8 LANG without TERM=dumb must default to true"
);
}
}