#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ColorSpec {
Direct(&'static str),
Bright(&'static str),
Rgb(u8, u8, u8),
Style(StyleName),
Dim,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum StyleName {
Error,
Warn,
Info,
Ok,
Header,
Subhead,
Emphasis,
Muted,
}
impl StyleName {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"ERROR" => StyleName::Error,
"WARN" => StyleName::Warn,
"INFO" => StyleName::Info,
"OK" => StyleName::Ok,
"HEADER" => StyleName::Header,
"SUBHEAD" => StyleName::Subhead,
"EMPHASIS" => StyleName::Emphasis,
"MUTED" => StyleName::Muted,
_ => return None,
})
}
pub fn resolve(self, palette: Palette) -> ColorSpec {
match (palette, self) {
(Palette::Wong, StyleName::Error) => ColorSpec::Rgb(214, 40, 40),
(Palette::Wong, StyleName::Warn) => ColorSpec::Rgb(247, 201, 72),
(Palette::Wong, StyleName::Info) => ColorSpec::Rgb(77, 201, 246),
(Palette::Wong, StyleName::Ok) => ColorSpec::Rgb(122, 193, 66),
(Palette::Wong, StyleName::Header) => ColorSpec::Rgb(255, 255, 255),
(Palette::Wong, StyleName::Subhead) => ColorSpec::Rgb(180, 180, 180),
(Palette::Wong, StyleName::Emphasis) => ColorSpec::Bright("WHITE"),
(Palette::Wong, StyleName::Muted) => ColorSpec::Dim,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum Palette {
#[default]
Wong,
}
impl ColorSpec {
pub fn parse(token: &str) -> Option<Self> {
if let Some(hex) = token.strip_prefix('#') {
return parse_hex(hex).map(|(r, g, b)| ColorSpec::Rgb(r, g, b));
}
if let Some(name) = token.strip_prefix("BRIGHT_")
&& direct_color_lookup(name).is_some()
{
return Some(ColorSpec::Bright(direct_color_canon(name)?));
}
if direct_color_lookup(token).is_some() {
return Some(ColorSpec::Direct(direct_color_canon(token)?));
}
if token == "DIM" {
return Some(ColorSpec::Dim);
}
StyleName::parse(token).map(ColorSpec::Style)
}
pub fn ansi_open(&self, palette: Palette, color_enabled: bool) -> String {
if !color_enabled {
return String::new();
}
match self {
ColorSpec::Direct(name) => {
format!("\x1b[{}m", direct_color_lookup(name).unwrap_or(0))
}
ColorSpec::Bright(name) => {
let base = direct_color_lookup(name).unwrap_or(0);
format!("\x1b[{}m", base + 60)
}
ColorSpec::Rgb(r, g, b) => {
format!("\x1b[38;2;{r};{g};{b}m")
}
ColorSpec::Style(s) => s.resolve(palette).ansi_open(palette, color_enabled),
ColorSpec::Dim => "\x1b[2m".to_string(),
}
}
pub fn ansi_close(&self, color_enabled: bool) -> &'static str {
if !color_enabled { "" } else { "\x1b[0m" }
}
}
fn direct_color_lookup(name: &str) -> Option<u8> {
Some(match name {
"BLACK" => 30,
"RED" => 31,
"GREEN" => 32,
"YELLOW" => 33,
"BLUE" => 34,
"MAGENTA" => 35,
"CYAN" => 36,
"WHITE" => 37,
_ => return None,
})
}
fn direct_color_canon(name: &str) -> Option<&'static str> {
Some(match name {
"BLACK" => "BLACK",
"RED" => "RED",
"GREEN" => "GREEN",
"YELLOW" => "YELLOW",
"BLUE" => "BLUE",
"MAGENTA" => "MAGENTA",
"CYAN" => "CYAN",
"WHITE" => "WHITE",
_ => return None,
})
}
fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
match s.len() {
6 => {
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some((r, g, b))
}
3 => {
let r = u8::from_str_radix(&s[0..1], 16).ok()? * 0x11;
let g = u8::from_str_radix(&s[1..2], 16).ok()? * 0x11;
let b = u8::from_str_radix(&s[2..3], 16).ok()? * 0x11;
Some((r, g, b))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_direct_color_names() {
assert_eq!(ColorSpec::parse("RED"), Some(ColorSpec::Direct("RED")));
assert_eq!(
ColorSpec::parse("YELLOW"),
Some(ColorSpec::Direct("YELLOW"))
);
assert_eq!(ColorSpec::parse("BLACK"), Some(ColorSpec::Direct("BLACK")));
}
#[test]
fn parses_bright_variants() {
assert_eq!(
ColorSpec::parse("BRIGHT_RED"),
Some(ColorSpec::Bright("RED"))
);
assert_eq!(
ColorSpec::parse("BRIGHT_WHITE"),
Some(ColorSpec::Bright("WHITE"))
);
}
#[test]
fn parses_dim() {
assert_eq!(ColorSpec::parse("DIM"), Some(ColorSpec::Dim));
}
#[test]
fn parses_hex_long_and_short() {
assert_eq!(
ColorSpec::parse("#7AC166"),
Some(ColorSpec::Rgb(0x7A, 0xC1, 0x66))
);
assert_eq!(
ColorSpec::parse("#FFF"),
Some(ColorSpec::Rgb(0xFF, 0xFF, 0xFF))
);
}
#[test]
fn parses_style_names() {
assert_eq!(
ColorSpec::parse("ERROR"),
Some(ColorSpec::Style(StyleName::Error))
);
assert_eq!(
ColorSpec::parse("INFO"),
Some(ColorSpec::Style(StyleName::Info))
);
}
#[test]
fn unknown_tokens_return_none() {
assert_eq!(ColorSpec::parse("not_a_color"), None);
assert_eq!(ColorSpec::parse("#GG0000"), None);
assert_eq!(ColorSpec::parse("#1234"), None);
}
#[test]
fn ansi_open_emits_correct_sgr() {
assert_eq!(
ColorSpec::Direct("RED").ansi_open(Palette::Wong, true),
"\x1b[31m",
);
assert_eq!(
ColorSpec::Bright("RED").ansi_open(Palette::Wong, true),
"\x1b[91m",
);
assert_eq!(
ColorSpec::Rgb(0x7A, 0xC1, 0x66).ansi_open(Palette::Wong, true),
"\x1b[38;2;122;193;102m",
);
assert_eq!(ColorSpec::Dim.ansi_open(Palette::Wong, true), "\x1b[2m",);
}
#[test]
fn ansi_disabled_emits_nothing() {
assert_eq!(ColorSpec::Direct("RED").ansi_open(Palette::Wong, false), "",);
assert_eq!(ColorSpec::Direct("RED").ansi_close(false), "");
}
#[test]
fn style_resolves_through_palette() {
let error = ColorSpec::Style(StyleName::Error);
let resolved = match error.clone() {
ColorSpec::Style(s) => s.resolve(Palette::Wong),
_ => unreachable!(),
};
assert!(matches!(resolved, ColorSpec::Rgb(214, 40, 40)));
}
}