1#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum ColorSpec {
26 Direct(&'static str),
28 Bright(&'static str),
30 Rgb(u8, u8, u8),
32 Style(StyleName),
35 Dim,
39}
40
41#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
45pub enum StyleName {
46 Error,
47 Warn,
48 Info,
49 Ok,
50 Header,
51 Subhead,
52 Emphasis,
53 Muted,
54}
55
56impl StyleName {
57 pub fn parse(s: &str) -> Option<Self> {
59 Some(match s {
60 "ERROR" => StyleName::Error,
61 "WARN" => StyleName::Warn,
62 "INFO" => StyleName::Info,
63 "OK" => StyleName::Ok,
64 "HEADER" => StyleName::Header,
65 "SUBHEAD" => StyleName::Subhead,
66 "EMPHASIS" => StyleName::Emphasis,
67 "MUTED" => StyleName::Muted,
68 _ => return None,
69 })
70 }
71
72 pub fn resolve(self, palette: Palette) -> ColorSpec {
77 match (palette, self) {
78 (Palette::Wong, StyleName::Error) => ColorSpec::Rgb(214, 40, 40),
83 (Palette::Wong, StyleName::Warn) => ColorSpec::Rgb(247, 201, 72),
84 (Palette::Wong, StyleName::Info) => ColorSpec::Rgb(77, 201, 246),
85 (Palette::Wong, StyleName::Ok) => ColorSpec::Rgb(122, 193, 66),
86 (Palette::Wong, StyleName::Header) => ColorSpec::Rgb(255, 255, 255),
87 (Palette::Wong, StyleName::Subhead) => ColorSpec::Rgb(180, 180, 180),
88 (Palette::Wong, StyleName::Emphasis) => ColorSpec::Bright("WHITE"),
89 (Palette::Wong, StyleName::Muted) => ColorSpec::Dim,
90 }
91 }
92}
93
94#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
97pub enum Palette {
98 #[default]
99 Wong,
100}
101
102impl ColorSpec {
103 pub fn parse(token: &str) -> Option<Self> {
108 if let Some(hex) = token.strip_prefix('#') {
109 return parse_hex(hex).map(|(r, g, b)| ColorSpec::Rgb(r, g, b));
110 }
111 if let Some(name) = token.strip_prefix("BRIGHT_")
113 && direct_color_lookup(name).is_some()
114 {
115 return Some(ColorSpec::Bright(direct_color_canon(name)?));
116 }
117 if direct_color_lookup(token).is_some() {
118 return Some(ColorSpec::Direct(direct_color_canon(token)?));
119 }
120 if token == "DIM" {
121 return Some(ColorSpec::Dim);
122 }
123 StyleName::parse(token).map(ColorSpec::Style)
124 }
125
126 pub fn ansi_open(&self, palette: Palette, color_enabled: bool) -> String {
129 if !color_enabled {
130 return String::new();
131 }
132 match self {
133 ColorSpec::Direct(name) => {
134 format!("\x1b[{}m", direct_color_lookup(name).unwrap_or(0))
135 }
136 ColorSpec::Bright(name) => {
137 let base = direct_color_lookup(name).unwrap_or(0);
138 format!("\x1b[{}m", base + 60)
142 }
143 ColorSpec::Rgb(r, g, b) => {
144 format!("\x1b[38;2;{r};{g};{b}m")
145 }
146 ColorSpec::Style(s) => s.resolve(palette).ansi_open(palette, color_enabled),
147 ColorSpec::Dim => "\x1b[2m".to_string(),
148 }
149 }
150
151 pub fn ansi_close(&self, color_enabled: bool) -> &'static str {
153 if !color_enabled { "" } else { "\x1b[0m" }
154 }
155}
156
157fn direct_color_lookup(name: &str) -> Option<u8> {
161 Some(match name {
162 "BLACK" => 30,
163 "RED" => 31,
164 "GREEN" => 32,
165 "YELLOW" => 33,
166 "BLUE" => 34,
167 "MAGENTA" => 35,
168 "CYAN" => 36,
169 "WHITE" => 37,
170 _ => return None,
171 })
172}
173
174fn direct_color_canon(name: &str) -> Option<&'static str> {
175 Some(match name {
176 "BLACK" => "BLACK",
177 "RED" => "RED",
178 "GREEN" => "GREEN",
179 "YELLOW" => "YELLOW",
180 "BLUE" => "BLUE",
181 "MAGENTA" => "MAGENTA",
182 "CYAN" => "CYAN",
183 "WHITE" => "WHITE",
184 _ => return None,
185 })
186}
187
188fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
191 match s.len() {
192 6 => {
193 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
194 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
195 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
196 Some((r, g, b))
197 }
198 3 => {
199 let r = u8::from_str_radix(&s[0..1], 16).ok()? * 0x11;
201 let g = u8::from_str_radix(&s[1..2], 16).ok()? * 0x11;
202 let b = u8::from_str_radix(&s[2..3], 16).ok()? * 0x11;
203 Some((r, g, b))
204 }
205 _ => None,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn parses_direct_color_names() {
215 assert_eq!(ColorSpec::parse("RED"), Some(ColorSpec::Direct("RED")));
216 assert_eq!(
217 ColorSpec::parse("YELLOW"),
218 Some(ColorSpec::Direct("YELLOW"))
219 );
220 assert_eq!(ColorSpec::parse("BLACK"), Some(ColorSpec::Direct("BLACK")));
221 }
222
223 #[test]
224 fn parses_bright_variants() {
225 assert_eq!(
226 ColorSpec::parse("BRIGHT_RED"),
227 Some(ColorSpec::Bright("RED"))
228 );
229 assert_eq!(
230 ColorSpec::parse("BRIGHT_WHITE"),
231 Some(ColorSpec::Bright("WHITE"))
232 );
233 }
234
235 #[test]
236 fn parses_dim() {
237 assert_eq!(ColorSpec::parse("DIM"), Some(ColorSpec::Dim));
238 }
239
240 #[test]
241 fn parses_hex_long_and_short() {
242 assert_eq!(
243 ColorSpec::parse("#7AC166"),
244 Some(ColorSpec::Rgb(0x7A, 0xC1, 0x66))
245 );
246 assert_eq!(
247 ColorSpec::parse("#FFF"),
248 Some(ColorSpec::Rgb(0xFF, 0xFF, 0xFF))
249 );
250 }
251
252 #[test]
253 fn parses_style_names() {
254 assert_eq!(
255 ColorSpec::parse("ERROR"),
256 Some(ColorSpec::Style(StyleName::Error))
257 );
258 assert_eq!(
259 ColorSpec::parse("INFO"),
260 Some(ColorSpec::Style(StyleName::Info))
261 );
262 }
263
264 #[test]
265 fn unknown_tokens_return_none() {
266 assert_eq!(ColorSpec::parse("not_a_color"), None);
267 assert_eq!(ColorSpec::parse("#GG0000"), None);
268 assert_eq!(ColorSpec::parse("#1234"), None);
269 }
270
271 #[test]
272 fn ansi_open_emits_correct_sgr() {
273 assert_eq!(
274 ColorSpec::Direct("RED").ansi_open(Palette::Wong, true),
275 "\x1b[31m",
276 );
277 assert_eq!(
278 ColorSpec::Bright("RED").ansi_open(Palette::Wong, true),
279 "\x1b[91m",
280 );
281 assert_eq!(
282 ColorSpec::Rgb(0x7A, 0xC1, 0x66).ansi_open(Palette::Wong, true),
283 "\x1b[38;2;122;193;102m",
284 );
285 assert_eq!(ColorSpec::Dim.ansi_open(Palette::Wong, true), "\x1b[2m",);
286 }
287
288 #[test]
289 fn ansi_disabled_emits_nothing() {
290 assert_eq!(ColorSpec::Direct("RED").ansi_open(Palette::Wong, false), "",);
291 assert_eq!(ColorSpec::Direct("RED").ansi_close(false), "");
292 }
293
294 #[test]
295 fn style_resolves_through_palette() {
296 let error = ColorSpec::Style(StyleName::Error);
297 let resolved = match error.clone() {
298 ColorSpec::Style(s) => s.resolve(Palette::Wong),
299 _ => unreachable!(),
300 };
301 assert!(matches!(resolved, ColorSpec::Rgb(214, 40, 40)));
302 }
303}