#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ColorMode {
Plain,
Ansi16,
Ansi256,
TrueColor,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[allow(missing_docs)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Color {
#[default]
Default,
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
Ansi256(u8),
Rgb(u8, u8, u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Resolved {
Default,
Indexed16(u8),
Indexed256(u8),
Rgb(u8, u8, u8),
}
impl Color {
pub(crate) fn resolve(self, mode: ColorMode) -> Resolved {
if mode == ColorMode::Plain {
return Resolved::Default;
}
match self {
Color::Default => Resolved::Default,
Color::Ansi256(index) => match mode {
ColorMode::Ansi16 => {
let (r, g, b) = ansi256_to_rgb(index);
Resolved::Indexed16(rgb_to_16(r, g, b))
}
_ => Resolved::Indexed256(index),
},
Color::Rgb(r, g, b) => match mode {
ColorMode::TrueColor => Resolved::Rgb(r, g, b),
ColorMode::Ansi256 => Resolved::Indexed256(rgb_to_256(r, g, b)),
_ => Resolved::Indexed16(rgb_to_16(r, g, b)),
},
named => Resolved::Indexed16(named.sgr()),
}
}
pub(crate) fn to_rgb(self) -> (u8, u8, u8) {
match self {
Color::Default => (128, 128, 128),
Color::Ansi256(index) => ansi256_to_rgb(index),
Color::Rgb(r, g, b) => (r, g, b),
named => {
let sgr = named.sgr();
let offset = if sgr >= 90 { sgr - 90 + 8 } else { sgr - 30 };
PALETTE16[offset as usize]
}
}
}
fn sgr(self) -> u8 {
match self {
Color::Black => 30,
Color::Red => 31,
Color::Green => 32,
Color::Yellow => 33,
Color::Blue => 34,
Color::Magenta => 35,
Color::Cyan => 36,
Color::White => 37,
Color::BrightBlack => 90,
Color::BrightRed => 91,
Color::BrightGreen => 92,
Color::BrightYellow => 93,
Color::BrightBlue => 94,
Color::BrightMagenta => 95,
Color::BrightCyan => 96,
Color::BrightWhite => 97,
_ => 39,
}
}
}
impl Resolved {
pub(crate) fn write_transition(
foreground: Option<Resolved>,
background: Option<Resolved>,
out: &mut String,
) {
if foreground.is_none() && background.is_none() {
return;
}
out.push_str("\x1b[");
if let Some(color) = foreground {
color.write_parameters(out, false);
if background.is_some() {
out.push(';');
}
}
if let Some(color) = background {
color.write_parameters(out, true);
}
out.push('m');
}
fn write_parameters(self, out: &mut String, background: bool) {
use std::fmt::Write as _;
let _ = match self {
Resolved::Default => write!(out, "{}", if background { 49 } else { 39 }),
Resolved::Indexed16(code) => {
write!(out, "{}", if background { code + 10 } else { code })
}
Resolved::Indexed256(index) => {
write!(out, "{};5;{index}", if background { 48 } else { 38 })
}
Resolved::Rgb(r, g, b) => {
write!(out, "{};2;{r};{g};{b}", if background { 48 } else { 38 })
}
};
}
}
const PALETTE16: [(u8, u8, u8); 16] = [
(0, 0, 0),
(205, 0, 0),
(0, 205, 0),
(205, 205, 0),
(0, 0, 238),
(205, 0, 205),
(0, 205, 205),
(229, 229, 229),
(127, 127, 127),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(92, 92, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
pub(crate) fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
if r == g && g == b {
let level = i32::from(r);
let step = ((level - 8 + 5).div_euclid(10)).clamp(0, 23);
let ramp = (232 + step) as u8;
let ramp_level = 8 + 10 * step;
let candidates = [(16u8, 0), (231, 255), (ramp, ramp_level)];
return candidates
.into_iter()
.min_by_key(|&(_, value)| ((value - level).abs(), value != ramp_level))
.map_or(ramp, |(index, _)| index);
}
let axis = |c: u8| match c {
0..=47 => 0u8,
48..=114 => 1,
c => (c - 35) / 40,
};
16 + 36 * axis(r) + 6 * axis(g) + axis(b)
}
pub(crate) fn ansi256_to_rgb(index: u8) -> (u8, u8, u8) {
match index {
0..=15 => PALETTE16[index as usize],
16..=231 => {
let n = index - 16;
(
CUBE[(n / 36) as usize],
CUBE[((n / 6) % 6) as usize],
CUBE[(n % 6) as usize],
)
}
gray => {
let v = 8 + 10 * (gray - 232);
(v, v, v)
}
}
}
pub(crate) fn oklab((r, g, b): (u8, u8, u8)) -> (f64, f64, f64) {
let linear = |channel: u8| {
let c = f64::from(channel) / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
let (r, g, b) = (linear(r), linear(g), linear(b));
let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
(
0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s,
1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s,
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s,
)
}
pub(crate) fn from_oklab((lightness, a, b): (f64, f64, f64)) -> (u8, u8, u8) {
let l = (lightness + 0.396_337_777_4 * a + 0.215_803_757_3 * b).powi(3);
let m = (lightness - 0.105_561_345_8 * a - 0.063_854_172_8 * b).powi(3);
let s = (lightness - 0.089_484_177_5 * a - 1.291_485_548 * b).powi(3);
let gamma = |c: f64| {
let c = if c.is_finite() {
c.clamp(0.0, 1.0)
} else {
0.0
};
let encoded = if c <= 0.003_130_8 {
12.92 * c
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0).round() as u8
};
(
gamma(4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s),
gamma(-1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s),
gamma(-0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701 * s),
)
}
const ACHROMATIC_CHROMA: f64 = 0.03;
const REFERENCE_CHROMA: f64 = 0.15;
pub(crate) fn rgb_to_16(r: u8, g: u8, b: u8) -> u8 {
let chroma = |(_, a, b): (f64, f64, f64)| a.hypot(b);
let place = |lab: (f64, f64, f64)| {
let c = chroma(lab);
if c > ACHROMATIC_CHROMA {
(
lab.0,
lab.1 / c * REFERENCE_CHROMA,
lab.2 / c * REFERENCE_CHROMA,
)
} else {
(lab.0, 0.0, 0.0)
}
};
let target = oklab((r, g, b));
let colored = chroma(target) > ACHROMATIC_CHROMA;
let target = place(target);
let mut best = 0usize;
let mut best_distance = f64::INFINITY;
for (index, &entry) in PALETTE16.iter().enumerate() {
let candidate = oklab(entry);
if (chroma(candidate) > ACHROMATIC_CHROMA) != colored {
continue;
}
let candidate = place(candidate);
let distance = (target.0 - candidate.0).powi(2)
+ (target.1 - candidate.1).powi(2)
+ (target.2 - candidate.2).powi(2);
if distance < best_distance {
best_distance = distance;
best = index;
}
}
if best < 8 {
30 + best as u8
} else {
90 + (best as u8 - 8)
}
}
#[cfg(test)]
#[path = "tests/color_tests.rs"]
mod tests;