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: f64) -> f64 {
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_f64(self) -> f64 {
105 let (r, g, b) = self.to_rgb();
106 let rf = to_linear(f64::from(r) / 255.0);
107 let gf = to_linear(f64::from(g) / 255.0);
108 let bf = to_linear(f64::from(b) / 255.0);
109 0.2126 * rf + 0.7152 * gf + 0.0722 * bf
110 }
111
112 #[deprecated(
114 since = "0.22.2",
115 note = "use Color::luminance_f64() to keep public float APIs on f64"
116 )]
117 pub fn luminance(self) -> f32 {
118 self.luminance_f64() as f32
119 }
120
121 pub fn contrast_fg(bg: Color) -> Color {
137 if bg.luminance_f64() > 0.179 {
138 Color::Rgb(0, 0, 0)
139 } else {
140 Color::Rgb(255, 255, 255)
141 }
142 }
143
144 pub fn blend_f64(self, other: Color, alpha: f64) -> Color {
160 let alpha = alpha.clamp(0.0, 1.0);
161 let (r1, g1, b1) = self.to_rgb();
162 let (r2, g2, b2) = other.to_rgb();
163 let r = (f64::from(r1) * alpha + f64::from(r2) * (1.0 - alpha)).round() as u8;
164 let g = (f64::from(g1) * alpha + f64::from(g2) * (1.0 - alpha)).round() as u8;
165 let b = (f64::from(b1) * alpha + f64::from(b2) * (1.0 - alpha)).round() as u8;
166 Color::Rgb(r, g, b)
167 }
168
169 #[deprecated(
171 since = "0.22.2",
172 note = "use Color::blend_f64() to keep public float APIs on f64"
173 )]
174 pub fn blend(self, other: Color, alpha: f32) -> Color {
175 self.blend_f64(other, f64::from(alpha))
176 }
177
178 pub fn lighten_f64(self, amount: f64) -> Color {
183 Color::Rgb(255, 255, 255).blend_f64(self, 1.0 - amount.clamp(0.0, 1.0))
184 }
185
186 #[deprecated(
188 since = "0.22.2",
189 note = "use Color::lighten_f64() to keep public float APIs on f64"
190 )]
191 pub fn lighten(self, amount: f32) -> Color {
192 self.lighten_f64(f64::from(amount))
193 }
194
195 pub fn darken_f64(self, amount: f64) -> Color {
200 Color::Rgb(0, 0, 0).blend_f64(self, 1.0 - amount.clamp(0.0, 1.0))
201 }
202
203 #[deprecated(
205 since = "0.22.2",
206 note = "use Color::darken_f64() to keep public float APIs on f64"
207 )]
208 pub fn darken(self, amount: f32) -> Color {
209 self.darken_f64(f64::from(amount))
210 }
211
212 pub fn contrast_ratio_f64(a: Color, b: Color) -> f64 {
226 let la = a.luminance_f64() + 0.05;
227 let lb = b.luminance_f64() + 0.05;
228 if la > lb { la / lb } else { lb / la }
229 }
230
231 #[deprecated(
233 since = "0.22.2",
234 note = "use Color::contrast_ratio_f64() to keep public float APIs on f64"
235 )]
236 pub fn contrast_ratio(a: Color, b: Color) -> f32 {
237 Self::contrast_ratio_f64(a, b) as f32
238 }
239
240 pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
243 Self::contrast_ratio_f64(fg, bg) >= 4.5
244 }
245
246 pub fn downsampled(self, depth: ColorDepth) -> Color {
256 match depth {
257 ColorDepth::TrueColor => self,
258 ColorDepth::EightBit => match self {
259 Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
260 other => other,
261 },
262 ColorDepth::Basic => match self {
263 Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
264 Color::Indexed(i) => {
265 let (r, g, b) = xterm256_to_rgb(i);
266 rgb_to_ansi16(r, g, b)
267 }
268 other => other,
269 },
270 ColorDepth::NoColor => Color::Reset,
271 }
272 }
273
274 #[doc(alias = "parse")]
291 pub fn from_hex(s: &str) -> Option<Color> {
292 let hex = s.strip_prefix('#')?;
293 match hex.len() {
294 3 => {
295 let mut it = hex.chars().map(|c| c.to_digit(16));
296 let r = it.next()??;
297 let g = it.next()??;
298 let b = it.next()??;
299 Some(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
301 }
302 6 => {
303 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
304 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
305 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
306 Some(Color::Rgb(r, g, b))
307 }
308 _ => None,
309 }
310 }
311
312 pub fn to_hex(self) -> String {
325 let (r, g, b) = self.to_rgb();
326 format!("#{r:02x}{g:02x}{b:02x}")
327 }
328
329 pub fn from_hsl_f64(h: f64, s: f64, l: f64) -> Color {
344 let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
345 Color::Rgb(r, g, b)
346 }
347
348 #[deprecated(
350 since = "0.22.2",
351 note = "use Color::from_hsl_f64() to keep public float APIs on f64"
352 )]
353 pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
354 Self::from_hsl_f64(f64::from(h), f64::from(s), f64::from(l))
355 }
356
357 pub fn from_hsv_f64(h: f64, s: f64, v: f64) -> Color {
372 let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
373 Color::Rgb(r, g, b)
374 }
375
376 #[deprecated(
378 since = "0.22.2",
379 note = "use Color::from_hsv_f64() to keep public float APIs on f64"
380 )]
381 pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
382 Self::from_hsv_f64(f64::from(h), f64::from(s), f64::from(v))
383 }
384
385 pub fn rotate_hue_f64(self, degrees: f64) -> Color {
402 let (r, g, b) = self.to_rgb();
403 let (h, s, l) = rgb_to_hsl(r, g, b);
404 let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
405 Color::Rgb(nr, ng, nb)
406 }
407
408 #[deprecated(
410 since = "0.22.2",
411 note = "use Color::rotate_hue_f64() to keep public float APIs on f64"
412 )]
413 pub fn rotate_hue(self, degrees: f32) -> Color {
414 self.rotate_hue_f64(f64::from(degrees))
415 }
416}
417
418impl From<(u8, u8, u8)> for Color {
419 fn from((r, g, b): (u8, u8, u8)) -> Color {
421 Color::Rgb(r, g, b)
422 }
423}
424
425impl From<[u8; 3]> for Color {
426 fn from([r, g, b]: [u8; 3]) -> Color {
428 Color::Rgb(r, g, b)
429 }
430}
431
432impl From<u32> for Color {
433 fn from(value: u32) -> Color {
445 let r = ((value >> 16) & 0xff) as u8;
446 let g = ((value >> 8) & 0xff) as u8;
447 let b = (value & 0xff) as u8;
448 Color::Rgb(r, g, b)
449 }
450}
451
452#[non_exhaustive]
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
467pub enum ColorParseError {
468 InvalidLength,
471 InvalidHexDigit,
473 Unknown,
475}
476
477impl std::fmt::Display for ColorParseError {
478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 let msg = match self {
480 ColorParseError::InvalidLength => "invalid color: hex form must have 3 or 6 digits",
481 ColorParseError::InvalidHexDigit => "invalid color: non-hex digit in hex form",
482 ColorParseError::Unknown => {
483 "invalid color: expected #rgb/#rrggbb, rrggbb, or a named color"
484 }
485 };
486 f.write_str(msg)
487 }
488}
489
490impl core::error::Error for ColorParseError {}
491
492impl std::str::FromStr for Color {
493 type Err = ColorParseError;
494
495 fn from_str(s: &str) -> Result<Color, ColorParseError> {
520 let trimmed = s.trim();
521
522 if let Some(c) = named_color(trimmed) {
525 return Ok(c);
526 }
527
528 let had_hash = trimmed.starts_with('#');
529 let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
530
531 match hex.len() {
532 3 => {
533 let mut it = hex.chars().map(|c| c.to_digit(16));
534 let r = it
535 .next()
536 .flatten()
537 .ok_or(ColorParseError::InvalidHexDigit)?;
538 let g = it
539 .next()
540 .flatten()
541 .ok_or(ColorParseError::InvalidHexDigit)?;
542 let b = it
543 .next()
544 .flatten()
545 .ok_or(ColorParseError::InvalidHexDigit)?;
546 Ok(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
547 }
548 6 => {
549 let r = u8::from_str_radix(&hex[0..2], 16)
550 .map_err(|_| ColorParseError::InvalidHexDigit)?;
551 let g = u8::from_str_radix(&hex[2..4], 16)
552 .map_err(|_| ColorParseError::InvalidHexDigit)?;
553 let b = u8::from_str_radix(&hex[4..6], 16)
554 .map_err(|_| ColorParseError::InvalidHexDigit)?;
555 Ok(Color::Rgb(r, g, b))
556 }
557 _ if had_hash => Err(ColorParseError::InvalidLength),
561 _ => Err(ColorParseError::Unknown),
562 }
563 }
564}
565
566fn named_color(s: &str) -> Option<Color> {
571 let lower = s.to_ascii_lowercase();
572 Some(match lower.as_str() {
573 "reset" | "default" => Color::Reset,
574 "black" => Color::Black,
575 "red" => Color::Red,
576 "green" => Color::Green,
577 "yellow" => Color::Yellow,
578 "blue" => Color::Blue,
579 "magenta" => Color::Magenta,
580 "cyan" => Color::Cyan,
581 "white" => Color::White,
582 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
583 "lightred" => Color::LightRed,
584 "lightgreen" => Color::LightGreen,
585 "lightyellow" => Color::LightYellow,
586 "lightblue" => Color::LightBlue,
587 "lightmagenta" => Color::LightMagenta,
588 "lightcyan" => Color::LightCyan,
589 "lightwhite" => Color::LightWhite,
590 _ => return None,
591 })
592}
593
594fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
599 let h = wrap_hue(h);
600 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
601 let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
602 let m = l - c / 2.0;
603 let (r1, g1, b1) = hue_sextant(h, c, x);
604 (
605 round_channel(r1 + m),
606 round_channel(g1 + m),
607 round_channel(b1 + m),
608 )
609}
610
611fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
616 let h = wrap_hue(h);
617 let c = v * s;
618 let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
619 let m = v - c;
620 let (r1, g1, b1) = hue_sextant(h, c, x);
621 (
622 round_channel(r1 + m),
623 round_channel(g1 + m),
624 round_channel(b1 + m),
625 )
626}
627
628fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
631 let rf = f64::from(r) / 255.0;
632 let gf = f64::from(g) / 255.0;
633 let bf = f64::from(b) / 255.0;
634 let max = rf.max(gf).max(bf);
635 let min = rf.min(gf).min(bf);
636 let delta = max - min;
637 let l = (max + min) / 2.0;
638
639 if delta <= f64::EPSILON {
640 return (0.0, 0.0, l);
642 }
643
644 let s = if l > 0.5 {
645 delta / (2.0 - max - min)
646 } else {
647 delta / (max + min)
648 };
649
650 let h = if max == rf {
651 let h = (gf - bf) / delta;
652 h % 6.0
653 } else if max == gf {
654 (bf - rf) / delta + 2.0
655 } else {
656 (rf - gf) / delta + 4.0
657 } * 60.0;
658
659 (wrap_hue(h), s, l)
660}
661
662#[inline]
665fn hue_sextant(h: f64, c: f64, x: f64) -> (f64, f64, f64) {
666 match h {
667 h if h < 60.0 => (c, x, 0.0),
668 h if h < 120.0 => (x, c, 0.0),
669 h if h < 180.0 => (0.0, c, x),
670 h if h < 240.0 => (0.0, x, c),
671 h if h < 300.0 => (x, 0.0, c),
672 _ => (c, 0.0, x),
673 }
674}
675
676#[inline]
678fn wrap_hue(h: f64) -> f64 {
679 let h = h % 360.0;
680 if h < 0.0 { h + 360.0 } else { h }
681}
682
683#[inline]
685fn round_channel(v: f64) -> u8 {
686 (v * 255.0).round().clamp(0.0, 255.0) as u8
687}
688
689#[cfg(feature = "serde")]
690impl Color {
691 fn named_token(self) -> Option<&'static str> {
693 Some(match self {
694 Color::Reset => "reset",
695 Color::Black => "black",
696 Color::Red => "red",
697 Color::Green => "green",
698 Color::Yellow => "yellow",
699 Color::Blue => "blue",
700 Color::Magenta => "magenta",
701 Color::Cyan => "cyan",
702 Color::White => "white",
703 Color::DarkGray => "darkgray",
704 Color::LightRed => "lightred",
705 Color::LightGreen => "lightgreen",
706 Color::LightYellow => "lightyellow",
707 Color::LightBlue => "lightblue",
708 Color::LightMagenta => "lightmagenta",
709 Color::LightCyan => "lightcyan",
710 Color::LightWhite => "lightwhite",
711 Color::Rgb(..) | Color::Indexed(_) => return None,
712 })
713 }
714
715 fn from_token(s: &str) -> Option<Color> {
721 if let Some(c) = Color::from_hex(s) {
722 return Some(c);
723 }
724 let lower = s.trim().to_ascii_lowercase();
725 if let Some(rest) = lower.strip_prefix("indexed:") {
726 return rest.trim().parse::<u8>().ok().map(Color::Indexed);
727 }
728 Some(match lower.as_str() {
729 "reset" | "default" => Color::Reset,
730 "black" => Color::Black,
731 "red" => Color::Red,
732 "green" => Color::Green,
733 "yellow" => Color::Yellow,
734 "blue" => Color::Blue,
735 "magenta" => Color::Magenta,
736 "cyan" => Color::Cyan,
737 "white" => Color::White,
738 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
739 "lightred" => Color::LightRed,
740 "lightgreen" => Color::LightGreen,
741 "lightyellow" => Color::LightYellow,
742 "lightblue" => Color::LightBlue,
743 "lightmagenta" => Color::LightMagenta,
744 "lightcyan" => Color::LightCyan,
745 "lightwhite" => Color::LightWhite,
746 _ => return None,
747 })
748 }
749
750 fn to_token(self) -> String {
755 if let Some(name) = self.named_token() {
756 return name.to_string();
757 }
758 match self {
759 Color::Indexed(n) => format!("indexed:{n}"),
760 other => other.to_hex(),
762 }
763 }
764}
765
766#[cfg(feature = "serde")]
767impl serde::Serialize for Color {
768 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
771 where
772 S: serde::Serializer,
773 {
774 serializer.serialize_str(&self.to_token())
775 }
776}
777
778#[cfg(feature = "serde")]
779impl<'de> serde::Deserialize<'de> for Color {
780 fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
783 where
784 D: serde::Deserializer<'de>,
785 {
786 struct ColorVisitor;
787
788 impl serde::de::Visitor<'_> for ColorVisitor {
789 type Value = Color;
790
791 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
792 f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
793 }
794
795 fn visit_str<E>(self, value: &str) -> Result<Color, E>
796 where
797 E: serde::de::Error,
798 {
799 Color::from_token(value).ok_or_else(|| {
800 E::custom(format!(
801 "invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
802 ))
803 })
804 }
805 }
806
807 deserializer.deserialize_str(ColorVisitor)
808 }
809}
810
811#[non_exhaustive]
817#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
818#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
819pub enum ColorDepth {
820 TrueColor,
822 EightBit,
824 Basic,
826 NoColor,
831}
832
833#[cfg(test)]
834mod color_depth_tests {
835 use super::{Color, ColorDepth};
836
837 #[test]
838 fn no_color_downsamples_everything_to_reset() {
839 assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
840 assert_eq!(
841 Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
842 Color::Reset
843 );
844 assert_eq!(
845 Color::Indexed(44).downsampled(ColorDepth::NoColor),
846 Color::Reset
847 );
848 }
849}
850
851impl ColorDepth {
852 pub fn detect() -> Self {
860 if std::env::var("NO_COLOR")
862 .ok()
863 .is_some_and(|v| !v.is_empty())
864 {
865 return Self::NoColor;
866 }
867 if let Ok(ct) = std::env::var("COLORTERM") {
868 let ct = ct.to_lowercase();
869 if ct == "truecolor" || ct == "24bit" {
870 return Self::TrueColor;
871 }
872 }
873 if let Ok(term) = std::env::var("TERM")
874 && term.contains("256color")
875 {
876 return Self::EightBit;
877 }
878 Self::Basic
879 }
880}
881
882fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
883 if r == g && g == b {
884 if r < 8 {
885 return 16;
886 }
887 if r >= 248 {
888 return 231;
889 }
890 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
891 }
892
893 let ri = if r < 48 {
894 0
895 } else {
896 ((r as u16 - 35) / 40) as u8
897 };
898 let gi = if g < 48 {
899 0
900 } else {
901 ((g as u16 - 35) / 40) as u8
902 };
903 let bi = if b < 48 {
904 0
905 } else {
906 ((b as u16 - 35) / 40) as u8
907 };
908 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
909}
910
911fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
912 let lum = 0.2126 * to_linear(f64::from(r) / 255.0)
913 + 0.7152 * to_linear(f64::from(g) / 255.0)
914 + 0.0722 * to_linear(f64::from(b) / 255.0);
915
916 let max = r.max(g).max(b);
917 let min = r.min(g).min(b);
918 let saturation = if max == 0 {
919 0.0
920 } else {
921 (max - min) as f32 / max as f32
922 };
923
924 if saturation < 0.2 {
925 return match lum {
927 l if l < 0.05 => Color::Black,
928 l if l < 0.25 => Color::DarkGray,
929 l if l < 0.7 => Color::White,
930 _ => Color::White, };
932 }
933
934 let bright = max >= 200 && min >= 64;
942
943 let rf = r as f32;
944 let gf = g as f32;
945 let bf = b as f32;
946
947 if rf >= gf && rf >= bf {
948 if gf > bf * 1.5 {
949 if bright {
950 Color::LightYellow
951 } else {
952 Color::Yellow
953 }
954 } else if bf > gf * 1.5 {
955 if bright {
956 Color::LightMagenta
957 } else {
958 Color::Magenta
959 }
960 } else if bright {
961 Color::LightRed
962 } else {
963 Color::Red
964 }
965 } else if gf >= rf && gf >= bf {
966 if bf > rf * 1.5 {
967 if bright {
968 Color::LightCyan
969 } else {
970 Color::Cyan
971 }
972 } else if bright {
973 Color::LightGreen
974 } else {
975 Color::Green
976 }
977 } else if rf > gf * 1.5 {
978 if bright {
979 Color::LightMagenta
980 } else {
981 Color::Magenta
982 }
983 } else if gf > rf * 1.5 {
984 if bright {
985 Color::LightCyan
986 } else {
987 Color::Cyan
988 }
989 } else if bright {
990 Color::LightBlue
991 } else {
992 Color::Blue
993 }
994}
995
996fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
997 match idx {
998 0 => (0, 0, 0),
999 1 => (128, 0, 0),
1000 2 => (0, 128, 0),
1001 3 => (128, 128, 0),
1002 4 => (0, 0, 128),
1003 5 => (128, 0, 128),
1004 6 => (0, 128, 128),
1005 7 => (192, 192, 192),
1006 8 => (128, 128, 128),
1007 9 => (255, 0, 0),
1008 10 => (0, 255, 0),
1009 11 => (255, 255, 0),
1010 12 => (0, 0, 255),
1011 13 => (255, 0, 255),
1012 14 => (0, 255, 255),
1013 15 => (255, 255, 255),
1014 16..=231 => {
1015 let n = idx - 16;
1016 let b_idx = n % 6;
1017 let g_idx = (n / 6) % 6;
1018 let r_idx = n / 36;
1019 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
1020 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
1021 }
1022 232..=255 => {
1023 let v = 8 + 10 * (idx - 232);
1024 (v, v, v)
1025 }
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 #![allow(clippy::unwrap_used)]
1032 use super::*;
1033
1034 #[test]
1035 fn blend_halfway_rounds_to_128() {
1036 assert_eq!(
1037 Color::Rgb(255, 255, 255).blend_f64(Color::Rgb(0, 0, 0), 0.5),
1038 Color::Rgb(128, 128, 128)
1039 );
1040 }
1041
1042 #[test]
1043 fn contrast_ratio_white_on_black_is_high() {
1044 let ratio = Color::contrast_ratio_f64(Color::White, Color::Black);
1045 assert!(ratio > 15.0);
1046 }
1047
1048 #[test]
1049 fn contrast_ratio_same_color_is_one() {
1050 let ratio = Color::contrast_ratio_f64(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
1051 assert!((ratio - 1.0).abs() < 0.01);
1052 }
1053
1054 #[test]
1055 fn meets_contrast_aa_white_on_black() {
1056 assert!(Color::meets_contrast_aa(Color::White, Color::Black));
1057 }
1058
1059 #[test]
1060 fn meets_contrast_aa_low_contrast_fails() {
1061 assert!(!Color::meets_contrast_aa(
1062 Color::Rgb(180, 180, 180),
1063 Color::Rgb(200, 200, 200)
1064 ));
1065 }
1066
1067 #[test]
1070 fn rgb_to_ansi256_no_overflow_full_range() {
1071 for r in 0u8..=255 {
1073 for g in 0u8..=255 {
1074 for b in 0u8..=255 {
1075 let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
1076 }
1077 }
1078 }
1079 }
1080
1081 #[test]
1082 fn rgb_248_maps_to_231() {
1083 assert_eq!(
1084 Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
1085 Color::Indexed(231)
1086 );
1087 }
1088
1089 #[test]
1092 fn luminance_dracula_purple_wcag() {
1093 let l = Color::Rgb(189, 147, 249).luminance_f64();
1094 assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
1095 }
1096
1097 #[test]
1098 fn contrast_aa_dracula_pair() {
1099 let p = Color::Rgb(189, 147, 249);
1100 let bg = Color::Rgb(40, 42, 54);
1101 assert!(Color::meets_contrast_aa(p, bg));
1102 let r = Color::contrast_ratio_f64(p, bg);
1103 assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
1104 }
1105
1106 #[test]
1107 fn contrast_white_on_black_is_21() {
1108 let r = Color::contrast_ratio_f64(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
1109 assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
1110 }
1111
1112 #[test]
1115 fn rgb_to_ansi16_bright_variants() {
1116 assert_eq!(
1118 Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
1119 Color::LightRed
1120 );
1121 assert_eq!(
1123 Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
1124 Color::Red
1125 );
1126 assert_eq!(
1128 Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
1129 Color::White
1130 );
1131 assert_eq!(
1133 Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
1134 Color::DarkGray
1135 );
1136 }
1137
1138 use std::str::FromStr;
1141
1142 #[test]
1143 fn from_tuple_and_array() {
1144 assert_eq!(Color::from((255, 107, 107)), Color::Rgb(255, 107, 107));
1145 assert_eq!(Color::from([1u8, 2, 3]), Color::Rgb(1, 2, 3));
1146 let c: Color = (10, 20, 30).into();
1148 assert_eq!(c, Color::Rgb(10, 20, 30));
1149 }
1150
1151 #[test]
1152 fn from_u32_packs_rrggbb() {
1153 assert_eq!(Color::from(0xff6b6b_u32), Color::Rgb(255, 107, 107));
1154 assert_eq!(Color::from(0x000000_u32), Color::Rgb(0, 0, 0));
1155 assert_eq!(Color::from(0xffffff_u32), Color::Rgb(255, 255, 255));
1156 assert_eq!(Color::from(0xff00ff00_u32), Color::Rgb(0, 255, 0));
1158 }
1159
1160 #[test]
1161 fn from_str_hex_round_trips() {
1162 assert_eq!(
1163 Color::from_str("#ff6b6b").unwrap(),
1164 Color::Rgb(255, 107, 107)
1165 );
1166 assert_eq!(
1168 Color::from_str("ff6b6b").unwrap(),
1169 Color::Rgb(255, 107, 107)
1170 );
1171 assert_eq!(Color::from_str("#abc").unwrap(), Color::Rgb(170, 187, 204));
1173 assert_eq!(Color::from_str("abc").unwrap(), Color::Rgb(170, 187, 204));
1174 assert_eq!(
1176 Color::from_str(" #ff6b6b ").unwrap(),
1177 Color::Rgb(255, 107, 107)
1178 );
1179 let c = Color::Rgb(18, 52, 86);
1181 assert_eq!(Color::from_str(&c.to_hex()).unwrap(), c);
1182 }
1183
1184 #[test]
1185 fn from_str_named_colors() {
1186 assert_eq!(Color::from_str("cyan").unwrap(), Color::Cyan);
1187 assert_eq!(Color::from_str("LightBlue").unwrap(), Color::LightBlue);
1188 assert_eq!(Color::from_str("DARKGRAY").unwrap(), Color::DarkGray);
1189 assert_eq!(Color::from_str("grey").unwrap(), Color::DarkGray);
1190 assert_eq!(Color::from_str("reset").unwrap(), Color::Reset);
1191 assert_eq!(Color::from_str("default").unwrap(), Color::Reset);
1192 }
1193
1194 #[test]
1195 fn from_str_error_cases() {
1196 assert_eq!(
1198 Color::from_str("#ff6b").unwrap_err(),
1199 ColorParseError::InvalidLength
1200 );
1201 assert_eq!(
1203 Color::from_str("#zz0011").unwrap_err(),
1204 ColorParseError::InvalidHexDigit
1205 );
1206 assert_eq!(
1208 Color::from_str("#xyz").unwrap_err(),
1209 ColorParseError::InvalidHexDigit
1210 );
1211 assert_eq!(
1213 Color::from_str("nope").unwrap_err(),
1214 ColorParseError::Unknown
1215 );
1216 assert_eq!(Color::from_str("").unwrap_err(), ColorParseError::Unknown);
1217 }
1218
1219 #[test]
1220 fn color_parse_error_display_and_error_trait() {
1221 let e = ColorParseError::InvalidLength;
1223 assert!(!e.to_string().is_empty());
1224 let _: &dyn std::error::Error = &e;
1225 }
1226
1227 #[test]
1228 fn from_hsl_primaries() {
1229 assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1230 assert_eq!(Color::from_hsl_f64(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
1231 assert_eq!(Color::from_hsl_f64(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1232 assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
1234 assert_eq!(
1235 Color::from_hsl_f64(0.0, 1.0, 1.0),
1236 Color::Rgb(255, 255, 255)
1237 );
1238 assert_eq!(
1240 Color::from_hsl_f64(123.0, 0.0, 0.5),
1241 Color::Rgb(128, 128, 128)
1242 );
1243 }
1244
1245 #[test]
1246 fn from_hsl_wraps_and_clamps() {
1247 assert_eq!(Color::from_hsl_f64(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1249 assert_eq!(Color::from_hsl_f64(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1251 assert_eq!(
1253 Color::from_hsl_f64(0.0, 5.0, 2.0),
1254 Color::Rgb(255, 255, 255)
1255 );
1256 }
1257
1258 #[test]
1259 fn from_hsv_primaries() {
1260 assert_eq!(Color::from_hsv_f64(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
1261 assert_eq!(Color::from_hsv_f64(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
1262 assert_eq!(Color::from_hsv_f64(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
1263 assert_eq!(
1265 Color::from_hsv_f64(0.0, 0.0, 1.0),
1266 Color::Rgb(255, 255, 255)
1267 );
1268 assert_eq!(Color::from_hsv_f64(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
1269 }
1270
1271 #[test]
1272 fn rotate_hue_primary_round_trip() {
1273 assert_eq!(
1275 Color::Rgb(255, 0, 0).rotate_hue_f64(120.0),
1276 Color::Rgb(0, 255, 0)
1277 );
1278 assert_eq!(
1279 Color::Rgb(0, 255, 0).rotate_hue_f64(120.0),
1280 Color::Rgb(0, 0, 255)
1281 );
1282 assert_eq!(
1284 Color::Rgb(255, 0, 0).rotate_hue_f64(180.0),
1285 Color::Rgb(0, 255, 255)
1286 );
1287 assert_eq!(
1289 Color::Rgb(255, 0, 0).rotate_hue_f64(360.0),
1290 Color::Rgb(255, 0, 0)
1291 );
1292 }
1293
1294 #[test]
1295 fn rotate_hue_resolves_named_to_rgb() {
1296 let rotated = Color::Red.rotate_hue_f64(0.0);
1298 assert_eq!(rotated, Color::Rgb(205, 49, 49));
1299 let gray = Color::Rgb(120, 120, 120).rotate_hue_f64(90.0);
1300 assert_eq!(gray, Color::Rgb(120, 120, 120));
1302 }
1303}