use std::fmt::{self, Write};
use crate::terminal::ColorLevel;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Color {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
Rgb(u8, u8, u8),
}
impl Color {
pub(crate) fn parse(value: &str) -> Option<Color> {
let value = value.trim();
if let Some(hex) = value.strip_prefix('#') {
return parse_hex(hex);
}
if value.contains(',') {
return parse_triple(value);
}
named(value)
}
pub(crate) fn from_hex(hex: &str) -> Option<Color> {
parse_hex(hex.strip_prefix('#').unwrap_or(hex))
}
pub(crate) fn write_fg<W: Write>(
self,
w: &mut W,
level: ColorLevel,
first: &mut bool,
) -> fmt::Result {
if level.is_none() {
return Ok(());
}
match self {
Color::Black => write_param(w, first, "30"),
Color::Red => write_param(w, first, "31"),
Color::Green => write_param(w, first, "32"),
Color::Yellow => write_param(w, first, "33"),
Color::Blue => write_param(w, first, "34"),
Color::Magenta => write_param(w, first, "35"),
Color::Cyan => write_param(w, first, "36"),
Color::White => write_param(w, first, "37"),
Color::Rgb(r, g, b) => match level {
ColorLevel::TrueColor => {
separator(w, first)?;
write!(w, "38;2;{r};{g};{b}")
}
ColorLevel::Ansi256 => {
separator(w, first)?;
write!(w, "38;5;{}", rgb_to_256(r, g, b))
}
ColorLevel::Ansi16 => {
separator(w, first)?;
write!(w, "{}", rgb_to_basic(r, g, b))
}
ColorLevel::None => Ok(()),
},
}
}
}
fn separator<W: Write>(w: &mut W, first: &mut bool) -> fmt::Result {
if *first {
*first = false;
Ok(())
} else {
w.write_char(';')
}
}
fn write_param<W: Write>(w: &mut W, first: &mut bool, param: &str) -> fmt::Result {
separator(w, first)?;
w.write_str(param)
}
fn named(value: &str) -> Option<Color> {
const NAMES: [(&str, Color); 8] = [
("black", Color::Black),
("red", Color::Red),
("green", Color::Green),
("yellow", Color::Yellow),
("blue", Color::Blue),
("magenta", Color::Magenta),
("cyan", Color::Cyan),
("white", Color::White),
];
NAMES
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(value))
.map(|&(_, color)| color)
}
fn parse_hex(hex: &str) -> Option<Color> {
if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
fn parse_triple(value: &str) -> Option<Color> {
let mut parts = value.split(',');
let r = parts.next()?.trim().parse::<u8>().ok()?;
let g = parts.next()?.trim().parse::<u8>().ok()?;
let b = parts.next()?.trim().parse::<u8>().ok()?;
if parts.next().is_some() {
return None;
}
Some(Color::Rgb(r, g, b))
}
fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
16 + 36 * cube_index(r) + 6 * cube_index(g) + cube_index(b)
}
fn cube_index(v: u8) -> u8 {
const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
LEVELS
.iter()
.enumerate()
.min_by_key(|&(_, &level)| (i16::from(level) - i16::from(v)).unsigned_abs())
.map_or(0, |(i, _)| i as u8)
}
fn rgb_to_basic(r: u8, g: u8, b: u8) -> u8 {
const PALETTE: [(u8, u8, u8, u8); 8] = [
(0, 0, 0, 30), (128, 0, 0, 31), (0, 128, 0, 32), (128, 128, 0, 33), (0, 0, 128, 34), (128, 0, 128, 35), (0, 128, 128, 36), (192, 192, 192, 37), ];
PALETTE
.iter()
.min_by_key(|&&(pr, pg, pb, _)| {
let dr = (i32::from(pr) - i32::from(r)).pow(2);
let dg = (i32::from(pg) - i32::from(g)).pow(2);
let db = (i32::from(pb) - i32::from(b)).pow(2);
(dr + dg + db) as u32
})
.map_or(37, |&(_, _, _, code)| code)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn fg(color: Color, level: ColorLevel) -> String {
let mut s = String::new();
let mut first = true;
color.write_fg(&mut s, level, &mut first).unwrap();
s
}
#[test]
fn test_parse_named_is_case_insensitive() {
assert_eq!(Color::parse("red"), Some(Color::Red));
assert_eq!(Color::parse("RED"), Some(Color::Red));
assert_eq!(Color::parse(" Cyan "), Some(Color::Cyan));
}
#[test]
fn test_parse_hex_and_triple() {
assert_eq!(Color::parse("#ff8800"), Some(Color::Rgb(255, 136, 0)));
assert_eq!(Color::parse("0,200,120"), Some(Color::Rgb(0, 200, 120)));
assert_eq!(Color::parse("255, 0, 0"), Some(Color::Rgb(255, 0, 0)));
}
#[test]
fn test_parse_rejects_garbage() {
assert_eq!(Color::parse("notacolor"), None);
assert_eq!(Color::parse("#fff"), None); assert_eq!(Color::parse("#gggggg"), None);
assert_eq!(Color::parse("1,2"), None);
assert_eq!(Color::parse("1,2,3,4"), None);
assert_eq!(Color::parse("0,0,256"), None); }
#[test]
fn test_from_hex_optional_hash() {
assert_eq!(Color::from_hex("#88aaff"), Some(Color::Rgb(136, 170, 255)));
assert_eq!(Color::from_hex("88aaff"), Some(Color::Rgb(136, 170, 255)));
assert_eq!(Color::from_hex("nope"), None);
}
#[test]
fn test_named_fg_codes() {
assert_eq!(fg(Color::Red, ColorLevel::Ansi16), "31");
assert_eq!(fg(Color::White, ColorLevel::TrueColor), "37");
assert_eq!(fg(Color::Black, ColorLevel::None), "");
}
#[test]
fn test_rgb_fg_per_level() {
let c = Color::Rgb(255, 136, 0);
assert_eq!(fg(c, ColorLevel::TrueColor), "38;2;255;136;0");
assert!(fg(c, ColorLevel::Ansi256).starts_with("38;5;"));
assert_eq!(fg(c, ColorLevel::None), "");
assert_eq!(fg(Color::Rgb(255, 0, 0), ColorLevel::Ansi16), "31");
}
#[test]
fn test_rgb_to_256_is_always_in_cube_range() {
for &(r, g, b) in &[(0, 0, 0), (255, 255, 255), (12, 200, 77), (250, 5, 130)] {
let idx = rgb_to_256(r, g, b);
assert!((16..=231).contains(&idx), "index {idx} out of cube range");
}
}
#[test]
fn test_separator_inserts_between_params() {
let mut s = String::new();
let mut first = true;
write_param(&mut s, &mut first, "1").unwrap();
write_param(&mut s, &mut first, "4").unwrap();
assert_eq!(s, "1;4");
}
}
#[cfg(test)]
mod proptests {
use proptest::prelude::*;
use super::*;
proptest! {
#[test]
fn test_parse_never_panics(value in ".*") {
let _ = Color::parse(&value);
let _ = Color::from_hex(&value);
}
#[test]
fn test_rgb_to_256_in_cube_range(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
prop_assert!((16..=231).contains(&rgb_to_256(r, g, b)));
}
#[test]
fn test_rgb_to_basic_in_range(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
prop_assert!((30..=37).contains(&rgb_to_basic(r, g, b)));
}
#[test]
fn test_hex_round_trip(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
let hex = format!("#{r:02x}{g:02x}{b:02x}");
prop_assert_eq!(Color::parse(&hex), Some(Color::Rgb(r, g, b)));
}
}
}