use std::io::IsTerminal;
use std::sync::OnceLock;
const fn fg(r: u8, g: u8, b: u8) -> Rgb {
Rgb(r, g, b)
}
#[derive(Clone, Copy)]
pub struct Rgb(u8, u8, u8);
pub const ACCENT: Rgb = fg(167, 139, 250); pub const TEAL: Rgb = fg(45, 212, 191); pub const OK: Rgb = fg(74, 222, 128); pub const WARN: Rgb = fg(251, 191, 36); pub const ERR: Rgb = fg(248, 113, 113); pub const SKY: Rgb = fg(56, 189, 248); pub const MUTED: Rgb = fg(161, 161, 170); pub const DIM: Rgb = fg(113, 113, 122);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ColorLevel {
None,
Ansi256,
TrueColor,
}
fn detect_color_level(
no_color: bool,
clicolor_force: bool,
term: Option<&str>,
colorterm: Option<&str>,
is_tty: bool,
) -> ColorLevel {
if no_color {
return ColorLevel::None;
}
if term == Some("dumb") {
return ColorLevel::None;
}
if !clicolor_force && !is_tty {
return ColorLevel::None;
}
match colorterm {
Some("truecolor") | Some("24bit") => ColorLevel::TrueColor,
_ => ColorLevel::Ansi256,
}
}
fn color_level() -> ColorLevel {
static LEVEL: OnceLock<ColorLevel> = OnceLock::new();
*LEVEL.get_or_init(|| {
detect_color_level(
std::env::var_os("NO_COLOR").is_some(),
std::env::var_os("CLICOLOR_FORCE").is_some(),
std::env::var("TERM").ok().as_deref(),
std::env::var("COLORTERM").ok().as_deref(),
std::io::stderr().is_terminal() || std::io::stdout().is_terminal(),
)
})
}
fn color_enabled() -> bool {
color_level() != ColorLevel::None
}
fn rgb_to_xterm256(r: u8, g: u8, b: u8) -> u8 {
const CUBE_STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
fn nearest_cube_level(v: u8) -> (i32, u8) {
CUBE_STEPS
.iter()
.enumerate()
.map(|(i, &s)| (i as i32, s))
.min_by_key(|&(_, s)| (s as i32 - v as i32).abs())
.expect("CUBE_STEPS is non-empty")
}
fn dist2(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> i32 {
let dr = r1 as i32 - r2 as i32;
let dg = g1 as i32 - g2 as i32;
let db = b1 as i32 - b2 as i32;
dr * dr + dg * dg + db * db
}
let (rl, rv) = nearest_cube_level(r);
let (gl, gv) = nearest_cube_level(g);
let (bl, bv) = nearest_cube_level(b);
let cube_idx = 16 + 36 * rl + 6 * gl + bl;
let cube_dist = dist2(r, g, b, rv, gv, bv);
let avg = (r as i32 + g as i32 + b as i32) as f64 / 3.0;
let gray_step = (((avg - 8.0) / 10.0).round()).clamp(0.0, 23.0) as i32;
let gray_val = (8 + gray_step * 10) as u8;
let gray_idx = 232 + gray_step;
let gray_dist = dist2(r, g, b, gray_val, gray_val, gray_val);
(if gray_dist < cube_dist {
gray_idx
} else {
cube_idx
}) as u8
}
fn fg_sgr(c: Rgb, level: ColorLevel) -> String {
match level {
ColorLevel::TrueColor => format!("38;2;{};{};{}", c.0, c.1, c.2),
ColorLevel::Ansi256 | ColorLevel::None => {
format!("38;5;{}", rgb_to_xterm256(c.0, c.1, c.2))
}
}
}
fn bg_sgr(c: Rgb, level: ColorLevel) -> String {
match level {
ColorLevel::TrueColor => format!("48;2;{};{};{}", c.0, c.1, c.2),
ColorLevel::Ansi256 | ColorLevel::None => {
format!("48;5;{}", rgb_to_xterm256(c.0, c.1, c.2))
}
}
}
pub fn paint(c: Rgb, s: impl AsRef<str>) -> String {
let s = s.as_ref();
let level = color_level();
if level != ColorLevel::None {
format!("\x1b[{}m{s}\x1b[0m", fg_sgr(c, level))
} else {
s.to_string()
}
}
pub fn bold(c: Rgb, s: impl AsRef<str>) -> String {
let s = s.as_ref();
let level = color_level();
if level != ColorLevel::None {
format!("\x1b[1;{}m{s}\x1b[0m", fg_sgr(c, level))
} else {
s.to_string()
}
}
pub fn bold_plain(s: impl AsRef<str>) -> String {
let s = s.as_ref();
if color_enabled() {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn pill(bg: Rgb, s: impl AsRef<str>) -> String {
let s = s.as_ref();
let level = color_level();
if level != ColorLevel::None {
const NEAR_BLACK: Rgb = Rgb(24, 24, 27);
format!(
"\x1b[1;{};{}m {s} \x1b[0m",
bg_sgr(bg, level),
fg_sgr(NEAR_BLACK, level)
)
} else {
format!("[{s}]")
}
}
pub fn mark(ok: bool) -> String {
if ok {
paint(OK, "✓")
} else {
paint(ERR, "✗")
}
}
pub fn logo_mark() -> String {
bold(ACCENT, "◆")
}
const RULE_WIDTH: usize = 60;
pub fn panel_top(title: &str) -> String {
format!(
"{} {}\n{}",
paint(ACCENT, "▌"),
bold_plain(title),
paint(DIM, "─".repeat(RULE_WIDTH)),
)
}
pub fn panel_bottom() -> String {
paint(DIM, "─".repeat(RULE_WIDTH))
}
pub fn section(title: &str) -> String {
format!("{} {}", paint(ACCENT, "▌"), bold_plain(title))
}
pub fn kv(key: &str, val: impl AsRef<str>) -> String {
format!(" {} {}", paint(MUTED, format!("{key:<12}")), val.as_ref())
}
pub fn kv_info(key: &str, val: impl AsRef<str>) -> String {
format!(
" {} {} {}",
paint(DIM, "◦"),
paint(MUTED, format!("{key:<12}")),
val.as_ref()
)
}
pub fn kv_blank(key: &str, val: impl AsRef<str>) -> String {
format!(
" {} {}",
paint(MUTED, format!("{key:<12}")),
val.as_ref()
)
}
pub fn kv_status(ok: bool, key: &str, val: impl AsRef<str>) -> String {
format!(
" {} {} {}",
mark(ok),
paint(MUTED, format!("{key:<12}")),
val.as_ref()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_color_wins_over_everything_else() {
assert_eq!(
detect_color_level(true, true, Some("xterm-256color"), Some("truecolor"), true),
ColorLevel::None
);
}
#[test]
fn dumb_term_disables_color() {
assert_eq!(
detect_color_level(false, false, Some("dumb"), None, true),
ColorLevel::None
);
}
#[test]
fn non_tty_disables_color_without_force() {
assert_eq!(
detect_color_level(false, false, Some("xterm-256color"), None, false),
ColorLevel::None
);
}
#[test]
fn clicolor_force_enables_color_on_non_tty() {
assert_eq!(
detect_color_level(false, true, Some("xterm-256color"), None, false),
ColorLevel::Ansi256
);
}
#[test]
fn unset_colorterm_on_256_term_degrades_to_ansi256() {
assert_eq!(
detect_color_level(false, false, Some("xterm-256color"), None, true),
ColorLevel::Ansi256
);
}
#[test]
fn colorterm_truecolor_selects_truecolor() {
assert_eq!(
detect_color_level(
false,
false,
Some("xterm-256color"),
Some("truecolor"),
true
),
ColorLevel::TrueColor
);
}
#[test]
fn colorterm_24bit_selects_truecolor() {
assert_eq!(
detect_color_level(false, false, Some("xterm-256color"), Some("24bit"), true),
ColorLevel::TrueColor
);
}
#[test]
fn unrecognized_colorterm_value_degrades_to_ansi256() {
assert_eq!(
detect_color_level(false, false, Some("xterm"), Some("bogus"), true),
ColorLevel::Ansi256
);
}
#[test]
fn xterm256_pure_black_and_white() {
assert_eq!(rgb_to_xterm256(0, 0, 0), 16); assert_eq!(rgb_to_xterm256(255, 255, 255), 231); }
#[test]
fn xterm256_mid_gray_uses_grayscale_ramp() {
let idx = rgb_to_xterm256(128, 128, 128);
assert!((232..=255).contains(&idx), "expected gray ramp, got {idx}");
}
#[test]
fn xterm256_palette_colors_land_in_valid_range() {
for c in [ACCENT, TEAL, OK, WARN, ERR, SKY, MUTED, DIM] {
let idx = rgb_to_xterm256(c.0, c.1, c.2);
assert!(
idx >= 16,
"{:?} mapped to reserved index {idx}",
(c.0, c.1, c.2)
);
}
}
#[test]
fn xterm256_is_deterministic() {
assert_eq!(
rgb_to_xterm256(167, 139, 250),
rgb_to_xterm256(167, 139, 250)
);
}
#[test]
fn paint_is_plain_when_color_disabled_by_non_tty() {
if !color_enabled() {
assert_eq!(paint(ACCENT, "hi"), "hi");
assert_eq!(bold(ACCENT, "hi"), "hi");
assert_eq!(pill(OK, "hi"), "[hi]");
}
}
}