#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Color {
Reset,
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
DarkGray,
LightRed,
LightGreen,
LightYellow,
LightBlue,
LightMagenta,
LightCyan,
LightWhite,
Rgb(u8, u8, u8),
Indexed(u8),
}
impl Color {
fn to_rgb(self) -> (u8, u8, u8) {
match self {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::Red => (205, 49, 49),
Color::Green => (13, 188, 121),
Color::Yellow => (229, 229, 16),
Color::Blue => (36, 114, 200),
Color::Magenta => (188, 63, 188),
Color::Cyan => (17, 168, 205),
Color::White => (229, 229, 229),
Color::DarkGray => (128, 128, 128),
Color::LightRed => (255, 0, 0),
Color::LightGreen => (0, 255, 0),
Color::LightYellow => (255, 255, 0),
Color::LightBlue => (0, 0, 255),
Color::LightMagenta => (255, 0, 255),
Color::LightCyan => (0, 255, 255),
Color::LightWhite => (255, 255, 255),
Color::Reset => (0, 0, 0),
Color::Indexed(idx) => xterm256_to_rgb(idx),
}
}
pub fn luminance(self) -> f32 {
let (r, g, b) = self.to_rgb();
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
0.2126 * rf + 0.7152 * gf + 0.0722 * bf
}
pub fn contrast_fg(bg: Color) -> Color {
if bg.luminance() > 0.5 {
Color::Rgb(0, 0, 0)
} else {
Color::Rgb(255, 255, 255)
}
}
pub fn blend(self, other: Color, alpha: f32) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
let (r1, g1, b1) = self.to_rgb();
let (r2, g2, b2) = other.to_rgb();
let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
Color::Rgb(r, g, b)
}
pub fn lighten(self, amount: f32) -> Color {
Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
}
pub fn darken(self, amount: f32) -> Color {
Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
}
pub fn contrast_ratio(a: Color, b: Color) -> f32 {
let la = a.luminance() + 0.05;
let lb = b.luminance() + 0.05;
if la > lb {
la / lb
} else {
lb / la
}
}
pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
Self::contrast_ratio(fg, bg) >= 4.5
}
pub fn downsampled(self, depth: ColorDepth) -> Color {
match depth {
ColorDepth::TrueColor => self,
ColorDepth::EightBit => match self {
Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
other => other,
},
ColorDepth::Basic => match self {
Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
Color::Indexed(i) => {
let (r, g, b) = xterm256_to_rgb(i);
rgb_to_ansi16(r, g, b)
}
other => other,
},
ColorDepth::NoColor => Color::Reset,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ColorDepth {
TrueColor,
EightBit,
Basic,
NoColor,
}
#[cfg(test)]
mod color_depth_tests {
use super::{Color, ColorDepth};
#[test]
fn no_color_downsamples_everything_to_reset() {
assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
assert_eq!(
Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
Color::Reset
);
assert_eq!(
Color::Indexed(44).downsampled(ColorDepth::NoColor),
Color::Reset
);
}
}
impl ColorDepth {
pub fn detect() -> Self {
if std::env::var("NO_COLOR")
.ok()
.is_some_and(|v| !v.is_empty())
{
return Self::NoColor;
}
if let Ok(ct) = std::env::var("COLORTERM") {
let ct = ct.to_lowercase();
if ct == "truecolor" || ct == "24bit" {
return Self::TrueColor;
}
}
if let Ok(term) = std::env::var("TERM") {
if term.contains("256color") {
return Self::EightBit;
}
}
Self::Basic
}
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
if r == g && g == b {
if r < 8 {
return 16;
}
if r > 248 {
return 231;
}
return 232 + (((r as u16 - 8) * 24 / 240) as u8);
}
let ri = if r < 48 {
0
} else {
((r as u16 - 35) / 40) as u8
};
let gi = if g < 48 {
0
} else {
((g as u16 - 35) / 40) as u8
};
let bi = if b < 48 {
0
} else {
((b as u16 - 35) / 40) as u8
};
16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
}
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
let lum =
0.2126 * (r as f32 / 255.0) + 0.7152 * (g as f32 / 255.0) + 0.0722 * (b as f32 / 255.0);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let saturation = if max == 0 {
0.0
} else {
(max - min) as f32 / max as f32
};
if saturation < 0.2 {
return if lum < 0.15 {
Color::Black
} else {
Color::White
};
}
let rf = r as f32;
let gf = g as f32;
let bf = b as f32;
if rf >= gf && rf >= bf {
if gf > bf * 1.5 {
Color::Yellow
} else if bf > gf * 1.5 {
Color::Magenta
} else {
Color::Red
}
} else if gf >= rf && gf >= bf {
if bf > rf * 1.5 {
Color::Cyan
} else {
Color::Green
}
} else if rf > gf * 1.5 {
Color::Magenta
} else if gf > rf * 1.5 {
Color::Cyan
} else {
Color::Blue
}
}
fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
match idx {
0 => (0, 0, 0),
1 => (128, 0, 0),
2 => (0, 128, 0),
3 => (128, 128, 0),
4 => (0, 0, 128),
5 => (128, 0, 128),
6 => (0, 128, 128),
7 => (192, 192, 192),
8 => (128, 128, 128),
9 => (255, 0, 0),
10 => (0, 255, 0),
11 => (255, 255, 0),
12 => (0, 0, 255),
13 => (255, 0, 255),
14 => (0, 255, 255),
15 => (255, 255, 255),
16..=231 => {
let n = idx - 16;
let b_idx = n % 6;
let g_idx = (n / 6) % 6;
let r_idx = n / 36;
let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
(to_val(r_idx), to_val(g_idx), to_val(b_idx))
}
232..=255 => {
let v = 8 + 10 * (idx - 232);
(v, v, v)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blend_halfway_rounds_to_128() {
assert_eq!(
Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
Color::Rgb(128, 128, 128)
);
}
#[test]
fn contrast_ratio_white_on_black_is_high() {
let ratio = Color::contrast_ratio(Color::White, Color::Black);
assert!(ratio > 15.0);
}
#[test]
fn contrast_ratio_same_color_is_one() {
let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
assert!((ratio - 1.0).abs() < 0.01);
}
#[test]
fn meets_contrast_aa_white_on_black() {
assert!(Color::meets_contrast_aa(Color::White, Color::Black));
}
#[test]
fn meets_contrast_aa_low_contrast_fails() {
assert!(!Color::meets_contrast_aa(
Color::Rgb(180, 180, 180),
Color::Rgb(200, 200, 200)
));
}
}