use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Depth {
#[default]
True,
Ansi256,
Ansi16,
}
impl Depth {
pub fn detect() -> Depth {
let var = |k: &str| std::env::var(k).unwrap_or_default().to_ascii_lowercase();
Depth::from_env(&var("KODACT_COLOR_DEPTH"), &var("COLORTERM"), &var("TERM"), &var("TERM_PROGRAM"), {
std::env::var_os("WT_SESSION").is_some()
})
}
fn from_env(forced: &str, colorterm: &str, term: &str, program: &str, windows_terminal: bool) -> Depth {
match forced {
"truecolor" | "24bit" | "true" => return Depth::True,
"256" => return Depth::Ansi256,
"16" | "8" => return Depth::Ansi16,
_ => {}
}
if matches!(colorterm, "truecolor" | "24bit")
|| term.contains("direct")
|| windows_terminal
|| matches!(program, "iterm.app" | "wezterm" | "vscode" | "ghostty" | "hyper" | "tabby")
{
Depth::True
} else if term.contains("256") {
Depth::Ansi256
} else if cfg!(windows) && term.is_empty() {
Depth::True
} else {
Depth::Ansi16
}
}
}
pub fn light_background() -> Option<bool> {
let v = std::env::var("COLORFGBG").ok()?;
let bg: u8 = v.rsplit(';').next()?.trim().parse().ok()?;
Some(matches!(bg, 7 | 15))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Role {
pub dark: u32,
pub light: u32,
pub ansi_dark: Color,
pub ansi_light: Color,
}
const fn role(dark: u32, light: u32, ansi_dark: Color, ansi_light: Color) -> Role {
Role { dark, light, ansi_dark, ansi_light }
}
impl Role {
pub fn color(&self, depth: Depth, light: bool) -> Color {
match depth {
Depth::True => rgb(if light { self.light } else { self.dark }),
Depth::Ansi256 => {
let (r, g, b) = split(if light { self.light } else { self.dark });
Color::Indexed(nearest_256(r, g, b))
}
Depth::Ansi16 => {
if light {
self.ansi_light
} else {
self.ansi_dark
}
}
}
}
}
pub const BRAND: Role = role(0xc6ff3d, 0x4a7200, Color::LightGreen, Color::Green);
pub const BRAND_STRONG: Role = role(0xdcff85, 0x3d5f00, Color::LightGreen, Color::Green);
pub const MUTED: Role = role(0xa9adb2, 0x55595e, Color::Gray, Color::DarkGray);
pub const SUBTLE: Role = role(0x868b92, 0x63676d, Color::DarkGray, Color::DarkGray);
pub const HAIRLINE: Role = role(0x2d3238, 0xd2d4d6, Color::DarkGray, Color::Gray);
pub const INFO: Role = role(0x8fbef0, 0x2f63a8, Color::LightBlue, Color::Blue);
pub const SUCCESS: Role = role(0x7ccf8f, 0x2f7a45, Color::LightGreen, Color::Green);
pub const WARNING: Role = role(0xf0c25e, 0x8f6200, Color::LightYellow, Color::Yellow);
pub const DANGER: Role = role(0xff7a6b, 0xb8392e, Color::LightRed, Color::Red);
pub const ADDED: Role = role(0x7ccf8f, 0x2f7a45, Color::LightGreen, Color::Green);
pub const REMOVED: Role = role(0xff7a6b, 0xb8392e, Color::LightRed, Color::Red);
pub const TEAL: Role = role(0x5fd8c4, 0x0e7568, Color::LightCyan, Color::Cyan);
pub const VIOLET: Role = role(0xb4a2ff, 0x5a44c0, Color::LightMagenta, Color::Magenta);
pub const CODE: Role = role(0xffb98f, 0xa4461c, Color::LightRed, Color::Red);
pub const ANSWER: Role = role(0xd9d7cf, 0x2b2e33, Color::Reset, Color::Reset);
pub const STRONG: Role = role(0xfbf9f3, 0x08090b, Color::White, Color::Black);
pub const CODE_BG: Role = role(0x22262b, 0xece6db, Color::Reset, Color::Reset);
pub const PROMPT_BG: Role = role(0x1b1f23, 0xebeae4, Color::DarkGray, Color::Gray);
pub const WARM_BG: Role = role(0x1c230f, 0xe8f1d2, Color::DarkGray, Color::Gray);
pub const PROMPT_FG: Role = role(0xf2f0e9, 0x101214, Color::White, Color::Black);
pub const SHIMMER_BASE: Role = role(0x80858b, 0x83878d, Color::Gray, Color::DarkGray);
pub const SHIMMER_PEAK: Role = role(0xdcff85, 0x4a7200, Color::White, Color::Black);
pub const CODE_TEXT: Role = role(0xcdd1d5, 0x33373b, Color::Gray, Color::DarkGray);
pub const CODE_KEYWORD: Role = role(0xb4a2ff, 0x5a44c0, Color::LightMagenta, Color::Magenta);
pub const CODE_STRING: Role = role(0xc9ec7a, 0x4f7000, Color::LightGreen, Color::Green);
pub const CODE_NUMBER: Role = role(0xf0c25e, 0x8f6200, Color::LightYellow, Color::Yellow);
pub const CODE_COMMENT: Role = role(0x868b92, 0x63676d, Color::DarkGray, Color::DarkGray);
pub const CODE_TYPE: Role = role(0x5fd8c4, 0x0e7568, Color::LightCyan, Color::Cyan);
pub const fn rgb(hex: u32) -> Color {
Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}
const fn split(hex: u32) -> (u8, u8, u8) {
((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}
pub fn ink(hex: u32, depth: Depth, ansi: Color) -> Color {
match depth {
Depth::True => rgb(hex),
Depth::Ansi256 => {
let (r, g, b) = split(hex);
Color::Indexed(nearest_256(r, g, b))
}
Depth::Ansi16 => ansi,
}
}
pub fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let step = |v: u8| -> usize {
let mut best = 0;
for (i, s) in STEPS.iter().enumerate() {
if (v as i32 - *s as i32).abs() < (v as i32 - STEPS[best] as i32).abs() {
best = i;
}
}
best
};
let dist = |a: (u8, u8, u8)| -> i32 {
let d = |x: u8, y: u8| (x as i32 - y as i32).pow(2);
3 * d(a.0, r) + 4 * d(a.1, g) + 2 * d(a.2, b)
};
let (ri, gi, bi) = (step(r), step(g), step(b));
let cube = (STEPS[ri], STEPS[gi], STEPS[bi]);
let cube_index = 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8;
let avg = (r as u32 + g as u32 + b as u32) / 3;
let grey_i = (((avg as i32 - 8).max(0) + 5) / 10).min(23) as u8;
let grey = 8 + 10 * grey_i;
if dist((grey, grey, grey)) < dist(cube) {
232 + grey_i
} else {
cube_index
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn depth_comes_from_what_the_terminal_says() {
assert_eq!(Depth::from_env("", "truecolor", "xterm-256color", "", false), Depth::True);
assert_eq!(Depth::from_env("", "", "xterm-256color", "", false), Depth::Ansi256);
assert_eq!(Depth::from_env("", "", "screen", "", true), Depth::True, "Windows Terminal");
assert_eq!(Depth::from_env("", "", "xterm", "iterm.app", false), Depth::True);
if !cfg!(windows) {
assert_eq!(Depth::from_env("", "", "xterm", "", false), Depth::Ansi16);
}
assert_eq!(Depth::from_env("256", "truecolor", "", "", false), Depth::Ansi256, "the override wins");
}
#[test]
fn the_nearest_256_colour_is_the_right_one() {
assert_eq!(nearest_256(0xff, 0xff, 0xff), 231);
assert_eq!(nearest_256(0, 0, 0), 16);
assert_eq!(nearest_256(0x80, 0x80, 0x80), 244);
let brand = nearest_256(0x94, 0x75, 0xdb);
assert!([98, 104, 140, 141].contains(&brand), "{brand}");
}
#[test]
fn roles_differ_for_light_and_dark_backgrounds() {
assert_eq!(BRAND.color(Depth::True, false), Color::Rgb(0xc6, 0xff, 0x3d));
assert_eq!(BRAND.color(Depth::True, true), Color::Rgb(0x4a, 0x72, 0x00));
assert_eq!(BRAND.color(Depth::Ansi16, false), Color::LightGreen);
assert!(matches!(BRAND.color(Depth::Ansi256, false), Color::Indexed(_)));
}
}