1#[non_exhaustive]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum Color {
9 Reset,
11 Black,
13 Red,
15 Green,
17 Yellow,
19 Blue,
21 Magenta,
23 Cyan,
25 White,
27 DarkGray,
29 LightRed,
31 LightGreen,
33 LightYellow,
35 LightBlue,
37 LightMagenta,
39 LightCyan,
41 LightWhite,
43 Rgb(u8, u8, u8),
45 Indexed(u8),
47}
48
49#[inline]
50fn to_linear(c: f32) -> f32 {
51 if c <= 0.04045 {
52 c / 12.92
53 } else {
54 ((c + 0.055) / 1.055).powf(2.4)
55 }
56}
57
58impl Color {
59 pub(crate) fn to_rgb(self) -> (u8, u8, u8) {
64 match self {
65 Color::Rgb(r, g, b) => (r, g, b),
66 Color::Black => (0, 0, 0),
67 Color::Red => (205, 49, 49),
68 Color::Green => (13, 188, 121),
69 Color::Yellow => (229, 229, 16),
70 Color::Blue => (36, 114, 200),
71 Color::Magenta => (188, 63, 188),
72 Color::Cyan => (17, 168, 205),
73 Color::White => (229, 229, 229),
74 Color::DarkGray => (128, 128, 128),
75 Color::LightRed => (255, 0, 0),
76 Color::LightGreen => (0, 255, 0),
77 Color::LightYellow => (255, 255, 0),
78 Color::LightBlue => (0, 0, 255),
79 Color::LightMagenta => (255, 0, 255),
80 Color::LightCyan => (0, 255, 255),
81 Color::LightWhite => (255, 255, 255),
82 Color::Reset => (0, 0, 0),
83 Color::Indexed(idx) => xterm256_to_rgb(idx),
84 }
85 }
86
87 pub fn luminance(self) -> f32 {
105 let (r, g, b) = self.to_rgb();
106 let rf = to_linear(r as f32 / 255.0);
107 let gf = to_linear(g as f32 / 255.0);
108 let bf = to_linear(b as f32 / 255.0);
109 0.2126 * rf + 0.7152 * gf + 0.0722 * bf
110 }
111
112 pub fn contrast_fg(bg: Color) -> Color {
128 if bg.luminance() > 0.179 {
129 Color::Rgb(0, 0, 0)
130 } else {
131 Color::Rgb(255, 255, 255)
132 }
133 }
134
135 pub fn blend(self, other: Color, alpha: f32) -> Color {
151 let alpha = alpha.clamp(0.0, 1.0);
152 let (r1, g1, b1) = self.to_rgb();
153 let (r2, g2, b2) = other.to_rgb();
154 let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
155 let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
156 let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
157 Color::Rgb(r, g, b)
158 }
159
160 pub fn lighten(self, amount: f32) -> Color {
165 Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
166 }
167
168 pub fn darken(self, amount: f32) -> Color {
173 Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
174 }
175
176 pub fn contrast_ratio(a: Color, b: Color) -> f32 {
190 let la = a.luminance() + 0.05;
191 let lb = b.luminance() + 0.05;
192 if la > lb {
193 la / lb
194 } else {
195 lb / la
196 }
197 }
198
199 pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
202 Self::contrast_ratio(fg, bg) >= 4.5
203 }
204
205 pub fn downsampled(self, depth: ColorDepth) -> Color {
215 match depth {
216 ColorDepth::TrueColor => self,
217 ColorDepth::EightBit => match self {
218 Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
219 other => other,
220 },
221 ColorDepth::Basic => match self {
222 Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
223 Color::Indexed(i) => {
224 let (r, g, b) = xterm256_to_rgb(i);
225 rgb_to_ansi16(r, g, b)
226 }
227 other => other,
228 },
229 ColorDepth::NoColor => Color::Reset,
230 }
231 }
232
233 #[doc(alias = "parse")]
250 pub fn from_hex(s: &str) -> Option<Color> {
251 let hex = s.strip_prefix('#')?;
252 match hex.len() {
253 3 => {
254 let mut it = hex.chars().map(|c| c.to_digit(16));
255 let r = it.next()??;
256 let g = it.next()??;
257 let b = it.next()??;
258 Some(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
260 }
261 6 => {
262 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
263 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
264 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
265 Some(Color::Rgb(r, g, b))
266 }
267 _ => None,
268 }
269 }
270
271 pub fn to_hex(self) -> String {
284 let (r, g, b) = self.to_rgb();
285 format!("#{r:02x}{g:02x}{b:02x}")
286 }
287}
288
289#[cfg(feature = "serde")]
290impl Color {
291 fn named_token(self) -> Option<&'static str> {
293 Some(match self {
294 Color::Reset => "reset",
295 Color::Black => "black",
296 Color::Red => "red",
297 Color::Green => "green",
298 Color::Yellow => "yellow",
299 Color::Blue => "blue",
300 Color::Magenta => "magenta",
301 Color::Cyan => "cyan",
302 Color::White => "white",
303 Color::DarkGray => "darkgray",
304 Color::LightRed => "lightred",
305 Color::LightGreen => "lightgreen",
306 Color::LightYellow => "lightyellow",
307 Color::LightBlue => "lightblue",
308 Color::LightMagenta => "lightmagenta",
309 Color::LightCyan => "lightcyan",
310 Color::LightWhite => "lightwhite",
311 Color::Rgb(..) | Color::Indexed(_) => return None,
312 })
313 }
314
315 fn from_token(s: &str) -> Option<Color> {
321 if let Some(c) = Color::from_hex(s) {
322 return Some(c);
323 }
324 let lower = s.trim().to_ascii_lowercase();
325 if let Some(rest) = lower.strip_prefix("indexed:") {
326 return rest.trim().parse::<u8>().ok().map(Color::Indexed);
327 }
328 Some(match lower.as_str() {
329 "reset" | "default" => Color::Reset,
330 "black" => Color::Black,
331 "red" => Color::Red,
332 "green" => Color::Green,
333 "yellow" => Color::Yellow,
334 "blue" => Color::Blue,
335 "magenta" => Color::Magenta,
336 "cyan" => Color::Cyan,
337 "white" => Color::White,
338 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
339 "lightred" => Color::LightRed,
340 "lightgreen" => Color::LightGreen,
341 "lightyellow" => Color::LightYellow,
342 "lightblue" => Color::LightBlue,
343 "lightmagenta" => Color::LightMagenta,
344 "lightcyan" => Color::LightCyan,
345 "lightwhite" => Color::LightWhite,
346 _ => return None,
347 })
348 }
349
350 fn to_token(self) -> String {
355 if let Some(name) = self.named_token() {
356 return name.to_string();
357 }
358 match self {
359 Color::Indexed(n) => format!("indexed:{n}"),
360 other => other.to_hex(),
362 }
363 }
364}
365
366#[cfg(feature = "serde")]
367impl serde::Serialize for Color {
368 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
371 where
372 S: serde::Serializer,
373 {
374 serializer.serialize_str(&self.to_token())
375 }
376}
377
378#[cfg(feature = "serde")]
379impl<'de> serde::Deserialize<'de> for Color {
380 fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
383 where
384 D: serde::Deserializer<'de>,
385 {
386 struct ColorVisitor;
387
388 impl serde::de::Visitor<'_> for ColorVisitor {
389 type Value = Color;
390
391 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
392 f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
393 }
394
395 fn visit_str<E>(self, value: &str) -> Result<Color, E>
396 where
397 E: serde::de::Error,
398 {
399 Color::from_token(value).ok_or_else(|| {
400 E::custom(format!(
401 "invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
402 ))
403 })
404 }
405 }
406
407 deserializer.deserialize_str(ColorVisitor)
408 }
409}
410
411#[non_exhaustive]
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
418#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
419pub enum ColorDepth {
420 TrueColor,
422 EightBit,
424 Basic,
426 NoColor,
431}
432
433#[cfg(test)]
434mod color_depth_tests {
435 use super::{Color, ColorDepth};
436
437 #[test]
438 fn no_color_downsamples_everything_to_reset() {
439 assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
440 assert_eq!(
441 Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
442 Color::Reset
443 );
444 assert_eq!(
445 Color::Indexed(44).downsampled(ColorDepth::NoColor),
446 Color::Reset
447 );
448 }
449}
450
451impl ColorDepth {
452 pub fn detect() -> Self {
460 if std::env::var("NO_COLOR")
462 .ok()
463 .is_some_and(|v| !v.is_empty())
464 {
465 return Self::NoColor;
466 }
467 if let Ok(ct) = std::env::var("COLORTERM") {
468 let ct = ct.to_lowercase();
469 if ct == "truecolor" || ct == "24bit" {
470 return Self::TrueColor;
471 }
472 }
473 if let Ok(term) = std::env::var("TERM") {
474 if term.contains("256color") {
475 return Self::EightBit;
476 }
477 }
478 Self::Basic
479 }
480}
481
482fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
483 if r == g && g == b {
484 if r < 8 {
485 return 16;
486 }
487 if r >= 248 {
488 return 231;
489 }
490 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
491 }
492
493 let ri = if r < 48 {
494 0
495 } else {
496 ((r as u16 - 35) / 40) as u8
497 };
498 let gi = if g < 48 {
499 0
500 } else {
501 ((g as u16 - 35) / 40) as u8
502 };
503 let bi = if b < 48 {
504 0
505 } else {
506 ((b as u16 - 35) / 40) as u8
507 };
508 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
509}
510
511fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
512 let lum = 0.2126 * to_linear(r as f32 / 255.0)
513 + 0.7152 * to_linear(g as f32 / 255.0)
514 + 0.0722 * to_linear(b as f32 / 255.0);
515
516 let max = r.max(g).max(b);
517 let min = r.min(g).min(b);
518 let saturation = if max == 0 {
519 0.0
520 } else {
521 (max - min) as f32 / max as f32
522 };
523
524 if saturation < 0.2 {
525 return match lum {
527 l if l < 0.05 => Color::Black,
528 l if l < 0.25 => Color::DarkGray,
529 l if l < 0.7 => Color::White,
530 _ => Color::White, };
532 }
533
534 let bright = max >= 200 && min >= 64;
542
543 let rf = r as f32;
544 let gf = g as f32;
545 let bf = b as f32;
546
547 if rf >= gf && rf >= bf {
548 if gf > bf * 1.5 {
549 if bright {
550 Color::LightYellow
551 } else {
552 Color::Yellow
553 }
554 } else if bf > gf * 1.5 {
555 if bright {
556 Color::LightMagenta
557 } else {
558 Color::Magenta
559 }
560 } else if bright {
561 Color::LightRed
562 } else {
563 Color::Red
564 }
565 } else if gf >= rf && gf >= bf {
566 if bf > rf * 1.5 {
567 if bright {
568 Color::LightCyan
569 } else {
570 Color::Cyan
571 }
572 } else if bright {
573 Color::LightGreen
574 } else {
575 Color::Green
576 }
577 } else if rf > gf * 1.5 {
578 if bright {
579 Color::LightMagenta
580 } else {
581 Color::Magenta
582 }
583 } else if gf > rf * 1.5 {
584 if bright {
585 Color::LightCyan
586 } else {
587 Color::Cyan
588 }
589 } else if bright {
590 Color::LightBlue
591 } else {
592 Color::Blue
593 }
594}
595
596fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
597 match idx {
598 0 => (0, 0, 0),
599 1 => (128, 0, 0),
600 2 => (0, 128, 0),
601 3 => (128, 128, 0),
602 4 => (0, 0, 128),
603 5 => (128, 0, 128),
604 6 => (0, 128, 128),
605 7 => (192, 192, 192),
606 8 => (128, 128, 128),
607 9 => (255, 0, 0),
608 10 => (0, 255, 0),
609 11 => (255, 255, 0),
610 12 => (0, 0, 255),
611 13 => (255, 0, 255),
612 14 => (0, 255, 255),
613 15 => (255, 255, 255),
614 16..=231 => {
615 let n = idx - 16;
616 let b_idx = n % 6;
617 let g_idx = (n / 6) % 6;
618 let r_idx = n / 36;
619 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
620 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
621 }
622 232..=255 => {
623 let v = 8 + 10 * (idx - 232);
624 (v, v, v)
625 }
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 #![allow(clippy::unwrap_used)]
632 use super::*;
633
634 #[test]
635 fn blend_halfway_rounds_to_128() {
636 assert_eq!(
637 Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
638 Color::Rgb(128, 128, 128)
639 );
640 }
641
642 #[test]
643 fn contrast_ratio_white_on_black_is_high() {
644 let ratio = Color::contrast_ratio(Color::White, Color::Black);
645 assert!(ratio > 15.0);
646 }
647
648 #[test]
649 fn contrast_ratio_same_color_is_one() {
650 let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
651 assert!((ratio - 1.0).abs() < 0.01);
652 }
653
654 #[test]
655 fn meets_contrast_aa_white_on_black() {
656 assert!(Color::meets_contrast_aa(Color::White, Color::Black));
657 }
658
659 #[test]
660 fn meets_contrast_aa_low_contrast_fails() {
661 assert!(!Color::meets_contrast_aa(
662 Color::Rgb(180, 180, 180),
663 Color::Rgb(200, 200, 200)
664 ));
665 }
666
667 #[test]
670 fn rgb_to_ansi256_no_overflow_full_range() {
671 for r in 0u8..=255 {
673 for g in 0u8..=255 {
674 for b in 0u8..=255 {
675 let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
676 }
677 }
678 }
679 }
680
681 #[test]
682 fn rgb_248_maps_to_231() {
683 assert_eq!(
684 Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
685 Color::Indexed(231)
686 );
687 }
688
689 #[test]
692 fn luminance_dracula_purple_wcag() {
693 let l = Color::Rgb(189, 147, 249).luminance();
694 assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
695 }
696
697 #[test]
698 fn contrast_aa_dracula_pair() {
699 let p = Color::Rgb(189, 147, 249);
700 let bg = Color::Rgb(40, 42, 54);
701 assert!(Color::meets_contrast_aa(p, bg));
702 let r = Color::contrast_ratio(p, bg);
703 assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
704 }
705
706 #[test]
707 fn contrast_white_on_black_is_21() {
708 let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
709 assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
710 }
711
712 #[test]
715 fn rgb_to_ansi16_bright_variants() {
716 assert_eq!(
718 Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
719 Color::LightRed
720 );
721 assert_eq!(
723 Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
724 Color::Red
725 );
726 assert_eq!(
728 Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
729 Color::White
730 );
731 assert_eq!(
733 Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
734 Color::DarkGray
735 );
736 }
737}