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 { la / lb } else { lb / la }
193 }
194
195 pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
198 Self::contrast_ratio(fg, bg) >= 4.5
199 }
200
201 pub fn downsampled(self, depth: ColorDepth) -> Color {
211 match depth {
212 ColorDepth::TrueColor => self,
213 ColorDepth::EightBit => match self {
214 Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
215 other => other,
216 },
217 ColorDepth::Basic => match self {
218 Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
219 Color::Indexed(i) => {
220 let (r, g, b) = xterm256_to_rgb(i);
221 rgb_to_ansi16(r, g, b)
222 }
223 other => other,
224 },
225 ColorDepth::NoColor => Color::Reset,
226 }
227 }
228
229 #[doc(alias = "parse")]
246 pub fn from_hex(s: &str) -> Option<Color> {
247 let hex = s.strip_prefix('#')?;
248 match hex.len() {
249 3 => {
250 let mut it = hex.chars().map(|c| c.to_digit(16));
251 let r = it.next()??;
252 let g = it.next()??;
253 let b = it.next()??;
254 Some(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
256 }
257 6 => {
258 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
259 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
260 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
261 Some(Color::Rgb(r, g, b))
262 }
263 _ => None,
264 }
265 }
266
267 pub fn to_hex(self) -> String {
280 let (r, g, b) = self.to_rgb();
281 format!("#{r:02x}{g:02x}{b:02x}")
282 }
283
284 pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
299 let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
300 Color::Rgb(r, g, b)
301 }
302
303 pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
318 let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
319 Color::Rgb(r, g, b)
320 }
321
322 pub fn rotate_hue(self, degrees: f32) -> Color {
339 let (r, g, b) = self.to_rgb();
340 let (h, s, l) = rgb_to_hsl(r, g, b);
341 let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
342 Color::Rgb(nr, ng, nb)
343 }
344}
345
346impl From<(u8, u8, u8)> for Color {
347 fn from((r, g, b): (u8, u8, u8)) -> Color {
349 Color::Rgb(r, g, b)
350 }
351}
352
353impl From<[u8; 3]> for Color {
354 fn from([r, g, b]: [u8; 3]) -> Color {
356 Color::Rgb(r, g, b)
357 }
358}
359
360impl From<u32> for Color {
361 fn from(value: u32) -> Color {
373 let r = ((value >> 16) & 0xff) as u8;
374 let g = ((value >> 8) & 0xff) as u8;
375 let b = (value & 0xff) as u8;
376 Color::Rgb(r, g, b)
377 }
378}
379
380#[non_exhaustive]
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum ColorParseError {
396 InvalidLength,
399 InvalidHexDigit,
401 Unknown,
403}
404
405impl std::fmt::Display for ColorParseError {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 let msg = match self {
408 ColorParseError::InvalidLength => "invalid color: hex form must have 3 or 6 digits",
409 ColorParseError::InvalidHexDigit => "invalid color: non-hex digit in hex form",
410 ColorParseError::Unknown => {
411 "invalid color: expected #rgb/#rrggbb, rrggbb, or a named color"
412 }
413 };
414 f.write_str(msg)
415 }
416}
417
418impl core::error::Error for ColorParseError {}
419
420impl std::str::FromStr for Color {
421 type Err = ColorParseError;
422
423 fn from_str(s: &str) -> Result<Color, ColorParseError> {
448 let trimmed = s.trim();
449
450 if let Some(c) = named_color(trimmed) {
453 return Ok(c);
454 }
455
456 let had_hash = trimmed.starts_with('#');
457 let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
458
459 match hex.len() {
460 3 => {
461 let mut it = hex.chars().map(|c| c.to_digit(16));
462 let r = it
463 .next()
464 .flatten()
465 .ok_or(ColorParseError::InvalidHexDigit)?;
466 let g = it
467 .next()
468 .flatten()
469 .ok_or(ColorParseError::InvalidHexDigit)?;
470 let b = it
471 .next()
472 .flatten()
473 .ok_or(ColorParseError::InvalidHexDigit)?;
474 Ok(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
475 }
476 6 => {
477 let r = u8::from_str_radix(&hex[0..2], 16)
478 .map_err(|_| ColorParseError::InvalidHexDigit)?;
479 let g = u8::from_str_radix(&hex[2..4], 16)
480 .map_err(|_| ColorParseError::InvalidHexDigit)?;
481 let b = u8::from_str_radix(&hex[4..6], 16)
482 .map_err(|_| ColorParseError::InvalidHexDigit)?;
483 Ok(Color::Rgb(r, g, b))
484 }
485 _ if had_hash => Err(ColorParseError::InvalidLength),
489 _ => Err(ColorParseError::Unknown),
490 }
491 }
492}
493
494fn named_color(s: &str) -> Option<Color> {
499 let lower = s.to_ascii_lowercase();
500 Some(match lower.as_str() {
501 "reset" | "default" => Color::Reset,
502 "black" => Color::Black,
503 "red" => Color::Red,
504 "green" => Color::Green,
505 "yellow" => Color::Yellow,
506 "blue" => Color::Blue,
507 "magenta" => Color::Magenta,
508 "cyan" => Color::Cyan,
509 "white" => Color::White,
510 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
511 "lightred" => Color::LightRed,
512 "lightgreen" => Color::LightGreen,
513 "lightyellow" => Color::LightYellow,
514 "lightblue" => Color::LightBlue,
515 "lightmagenta" => Color::LightMagenta,
516 "lightcyan" => Color::LightCyan,
517 "lightwhite" => Color::LightWhite,
518 _ => return None,
519 })
520}
521
522fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
527 let h = wrap_hue(h);
528 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
529 let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
530 let m = l - c / 2.0;
531 let (r1, g1, b1) = hue_sextant(h, c, x);
532 (
533 round_channel(r1 + m),
534 round_channel(g1 + m),
535 round_channel(b1 + m),
536 )
537}
538
539fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
544 let h = wrap_hue(h);
545 let c = v * s;
546 let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
547 let m = v - c;
548 let (r1, g1, b1) = hue_sextant(h, c, x);
549 (
550 round_channel(r1 + m),
551 round_channel(g1 + m),
552 round_channel(b1 + m),
553 )
554}
555
556fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
559 let rf = r as f32 / 255.0;
560 let gf = g as f32 / 255.0;
561 let bf = b as f32 / 255.0;
562 let max = rf.max(gf).max(bf);
563 let min = rf.min(gf).min(bf);
564 let delta = max - min;
565 let l = (max + min) / 2.0;
566
567 if delta <= f32::EPSILON {
568 return (0.0, 0.0, l);
570 }
571
572 let s = if l > 0.5 {
573 delta / (2.0 - max - min)
574 } else {
575 delta / (max + min)
576 };
577
578 let h = if max == rf {
579 let h = (gf - bf) / delta;
580 h % 6.0
581 } else if max == gf {
582 (bf - rf) / delta + 2.0
583 } else {
584 (rf - gf) / delta + 4.0
585 } * 60.0;
586
587 (wrap_hue(h), s, l)
588}
589
590#[inline]
593fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
594 match h {
595 h if h < 60.0 => (c, x, 0.0),
596 h if h < 120.0 => (x, c, 0.0),
597 h if h < 180.0 => (0.0, c, x),
598 h if h < 240.0 => (0.0, x, c),
599 h if h < 300.0 => (x, 0.0, c),
600 _ => (c, 0.0, x),
601 }
602}
603
604#[inline]
606fn wrap_hue(h: f32) -> f32 {
607 let h = h % 360.0;
608 if h < 0.0 { h + 360.0 } else { h }
609}
610
611#[inline]
613fn round_channel(v: f32) -> u8 {
614 (v * 255.0).round().clamp(0.0, 255.0) as u8
615}
616
617#[cfg(feature = "serde")]
618impl Color {
619 fn named_token(self) -> Option<&'static str> {
621 Some(match self {
622 Color::Reset => "reset",
623 Color::Black => "black",
624 Color::Red => "red",
625 Color::Green => "green",
626 Color::Yellow => "yellow",
627 Color::Blue => "blue",
628 Color::Magenta => "magenta",
629 Color::Cyan => "cyan",
630 Color::White => "white",
631 Color::DarkGray => "darkgray",
632 Color::LightRed => "lightred",
633 Color::LightGreen => "lightgreen",
634 Color::LightYellow => "lightyellow",
635 Color::LightBlue => "lightblue",
636 Color::LightMagenta => "lightmagenta",
637 Color::LightCyan => "lightcyan",
638 Color::LightWhite => "lightwhite",
639 Color::Rgb(..) | Color::Indexed(_) => return None,
640 })
641 }
642
643 fn from_token(s: &str) -> Option<Color> {
649 if let Some(c) = Color::from_hex(s) {
650 return Some(c);
651 }
652 let lower = s.trim().to_ascii_lowercase();
653 if let Some(rest) = lower.strip_prefix("indexed:") {
654 return rest.trim().parse::<u8>().ok().map(Color::Indexed);
655 }
656 Some(match lower.as_str() {
657 "reset" | "default" => Color::Reset,
658 "black" => Color::Black,
659 "red" => Color::Red,
660 "green" => Color::Green,
661 "yellow" => Color::Yellow,
662 "blue" => Color::Blue,
663 "magenta" => Color::Magenta,
664 "cyan" => Color::Cyan,
665 "white" => Color::White,
666 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
667 "lightred" => Color::LightRed,
668 "lightgreen" => Color::LightGreen,
669 "lightyellow" => Color::LightYellow,
670 "lightblue" => Color::LightBlue,
671 "lightmagenta" => Color::LightMagenta,
672 "lightcyan" => Color::LightCyan,
673 "lightwhite" => Color::LightWhite,
674 _ => return None,
675 })
676 }
677
678 fn to_token(self) -> String {
683 if let Some(name) = self.named_token() {
684 return name.to_string();
685 }
686 match self {
687 Color::Indexed(n) => format!("indexed:{n}"),
688 other => other.to_hex(),
690 }
691 }
692}
693
694#[cfg(feature = "serde")]
695impl serde::Serialize for Color {
696 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
699 where
700 S: serde::Serializer,
701 {
702 serializer.serialize_str(&self.to_token())
703 }
704}
705
706#[cfg(feature = "serde")]
707impl<'de> serde::Deserialize<'de> for Color {
708 fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
711 where
712 D: serde::Deserializer<'de>,
713 {
714 struct ColorVisitor;
715
716 impl serde::de::Visitor<'_> for ColorVisitor {
717 type Value = Color;
718
719 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
720 f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
721 }
722
723 fn visit_str<E>(self, value: &str) -> Result<Color, E>
724 where
725 E: serde::de::Error,
726 {
727 Color::from_token(value).ok_or_else(|| {
728 E::custom(format!(
729 "invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
730 ))
731 })
732 }
733 }
734
735 deserializer.deserialize_str(ColorVisitor)
736 }
737}
738
739#[non_exhaustive]
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
746#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
747pub enum ColorDepth {
748 TrueColor,
750 EightBit,
752 Basic,
754 NoColor,
759}
760
761#[cfg(test)]
762mod color_depth_tests {
763 use super::{Color, ColorDepth};
764
765 #[test]
766 fn no_color_downsamples_everything_to_reset() {
767 assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
768 assert_eq!(
769 Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
770 Color::Reset
771 );
772 assert_eq!(
773 Color::Indexed(44).downsampled(ColorDepth::NoColor),
774 Color::Reset
775 );
776 }
777}
778
779impl ColorDepth {
780 pub fn detect() -> Self {
788 if std::env::var("NO_COLOR")
790 .ok()
791 .is_some_and(|v| !v.is_empty())
792 {
793 return Self::NoColor;
794 }
795 if let Ok(ct) = std::env::var("COLORTERM") {
796 let ct = ct.to_lowercase();
797 if ct == "truecolor" || ct == "24bit" {
798 return Self::TrueColor;
799 }
800 }
801 if let Ok(term) = std::env::var("TERM")
802 && term.contains("256color")
803 {
804 return Self::EightBit;
805 }
806 Self::Basic
807 }
808}
809
810fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
811 if r == g && g == b {
812 if r < 8 {
813 return 16;
814 }
815 if r >= 248 {
816 return 231;
817 }
818 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
819 }
820
821 let ri = if r < 48 {
822 0
823 } else {
824 ((r as u16 - 35) / 40) as u8
825 };
826 let gi = if g < 48 {
827 0
828 } else {
829 ((g as u16 - 35) / 40) as u8
830 };
831 let bi = if b < 48 {
832 0
833 } else {
834 ((b as u16 - 35) / 40) as u8
835 };
836 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
837}
838
839fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
840 let lum = 0.2126 * to_linear(r as f32 / 255.0)
841 + 0.7152 * to_linear(g as f32 / 255.0)
842 + 0.0722 * to_linear(b as f32 / 255.0);
843
844 let max = r.max(g).max(b);
845 let min = r.min(g).min(b);
846 let saturation = if max == 0 {
847 0.0
848 } else {
849 (max - min) as f32 / max as f32
850 };
851
852 if saturation < 0.2 {
853 return match lum {
855 l if l < 0.05 => Color::Black,
856 l if l < 0.25 => Color::DarkGray,
857 l if l < 0.7 => Color::White,
858 _ => Color::White, };
860 }
861
862 let bright = max >= 200 && min >= 64;
870
871 let rf = r as f32;
872 let gf = g as f32;
873 let bf = b as f32;
874
875 if rf >= gf && rf >= bf {
876 if gf > bf * 1.5 {
877 if bright {
878 Color::LightYellow
879 } else {
880 Color::Yellow
881 }
882 } else if bf > gf * 1.5 {
883 if bright {
884 Color::LightMagenta
885 } else {
886 Color::Magenta
887 }
888 } else if bright {
889 Color::LightRed
890 } else {
891 Color::Red
892 }
893 } else if gf >= rf && gf >= bf {
894 if bf > rf * 1.5 {
895 if bright {
896 Color::LightCyan
897 } else {
898 Color::Cyan
899 }
900 } else if bright {
901 Color::LightGreen
902 } else {
903 Color::Green
904 }
905 } else if rf > gf * 1.5 {
906 if bright {
907 Color::LightMagenta
908 } else {
909 Color::Magenta
910 }
911 } else if gf > rf * 1.5 {
912 if bright {
913 Color::LightCyan
914 } else {
915 Color::Cyan
916 }
917 } else if bright {
918 Color::LightBlue
919 } else {
920 Color::Blue
921 }
922}
923
924fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
925 match idx {
926 0 => (0, 0, 0),
927 1 => (128, 0, 0),
928 2 => (0, 128, 0),
929 3 => (128, 128, 0),
930 4 => (0, 0, 128),
931 5 => (128, 0, 128),
932 6 => (0, 128, 128),
933 7 => (192, 192, 192),
934 8 => (128, 128, 128),
935 9 => (255, 0, 0),
936 10 => (0, 255, 0),
937 11 => (255, 255, 0),
938 12 => (0, 0, 255),
939 13 => (255, 0, 255),
940 14 => (0, 255, 255),
941 15 => (255, 255, 255),
942 16..=231 => {
943 let n = idx - 16;
944 let b_idx = n % 6;
945 let g_idx = (n / 6) % 6;
946 let r_idx = n / 36;
947 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
948 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
949 }
950 232..=255 => {
951 let v = 8 + 10 * (idx - 232);
952 (v, v, v)
953 }
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 #![allow(clippy::unwrap_used)]
960 use super::*;
961
962 #[test]
963 fn blend_halfway_rounds_to_128() {
964 assert_eq!(
965 Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
966 Color::Rgb(128, 128, 128)
967 );
968 }
969
970 #[test]
971 fn contrast_ratio_white_on_black_is_high() {
972 let ratio = Color::contrast_ratio(Color::White, Color::Black);
973 assert!(ratio > 15.0);
974 }
975
976 #[test]
977 fn contrast_ratio_same_color_is_one() {
978 let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
979 assert!((ratio - 1.0).abs() < 0.01);
980 }
981
982 #[test]
983 fn meets_contrast_aa_white_on_black() {
984 assert!(Color::meets_contrast_aa(Color::White, Color::Black));
985 }
986
987 #[test]
988 fn meets_contrast_aa_low_contrast_fails() {
989 assert!(!Color::meets_contrast_aa(
990 Color::Rgb(180, 180, 180),
991 Color::Rgb(200, 200, 200)
992 ));
993 }
994
995 #[test]
998 fn rgb_to_ansi256_no_overflow_full_range() {
999 for r in 0u8..=255 {
1001 for g in 0u8..=255 {
1002 for b in 0u8..=255 {
1003 let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
1004 }
1005 }
1006 }
1007 }
1008
1009 #[test]
1010 fn rgb_248_maps_to_231() {
1011 assert_eq!(
1012 Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
1013 Color::Indexed(231)
1014 );
1015 }
1016
1017 #[test]
1020 fn luminance_dracula_purple_wcag() {
1021 let l = Color::Rgb(189, 147, 249).luminance();
1022 assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
1023 }
1024
1025 #[test]
1026 fn contrast_aa_dracula_pair() {
1027 let p = Color::Rgb(189, 147, 249);
1028 let bg = Color::Rgb(40, 42, 54);
1029 assert!(Color::meets_contrast_aa(p, bg));
1030 let r = Color::contrast_ratio(p, bg);
1031 assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
1032 }
1033
1034 #[test]
1035 fn contrast_white_on_black_is_21() {
1036 let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
1037 assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
1038 }
1039
1040 #[test]
1043 fn rgb_to_ansi16_bright_variants() {
1044 assert_eq!(
1046 Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
1047 Color::LightRed
1048 );
1049 assert_eq!(
1051 Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
1052 Color::Red
1053 );
1054 assert_eq!(
1056 Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
1057 Color::White
1058 );
1059 assert_eq!(
1061 Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
1062 Color::DarkGray
1063 );
1064 }
1065
1066 use std::str::FromStr;
1069
1070 #[test]
1071 fn from_tuple_and_array() {
1072 assert_eq!(Color::from((255, 107, 107)), Color::Rgb(255, 107, 107));
1073 assert_eq!(Color::from([1u8, 2, 3]), Color::Rgb(1, 2, 3));
1074 let c: Color = (10, 20, 30).into();
1076 assert_eq!(c, Color::Rgb(10, 20, 30));
1077 }
1078
1079 #[test]
1080 fn from_u32_packs_rrggbb() {
1081 assert_eq!(Color::from(0xff6b6b_u32), Color::Rgb(255, 107, 107));
1082 assert_eq!(Color::from(0x000000_u32), Color::Rgb(0, 0, 0));
1083 assert_eq!(Color::from(0xffffff_u32), Color::Rgb(255, 255, 255));
1084 assert_eq!(Color::from(0xff00ff00_u32), Color::Rgb(0, 255, 0));
1086 }
1087
1088 #[test]
1089 fn from_str_hex_round_trips() {
1090 assert_eq!(
1091 Color::from_str("#ff6b6b").unwrap(),
1092 Color::Rgb(255, 107, 107)
1093 );
1094 assert_eq!(
1096 Color::from_str("ff6b6b").unwrap(),
1097 Color::Rgb(255, 107, 107)
1098 );
1099 assert_eq!(Color::from_str("#abc").unwrap(), Color::Rgb(170, 187, 204));
1101 assert_eq!(Color::from_str("abc").unwrap(), Color::Rgb(170, 187, 204));
1102 assert_eq!(
1104 Color::from_str(" #ff6b6b ").unwrap(),
1105 Color::Rgb(255, 107, 107)
1106 );
1107 let c = Color::Rgb(18, 52, 86);
1109 assert_eq!(Color::from_str(&c.to_hex()).unwrap(), c);
1110 }
1111
1112 #[test]
1113 fn from_str_named_colors() {
1114 assert_eq!(Color::from_str("cyan").unwrap(), Color::Cyan);
1115 assert_eq!(Color::from_str("LightBlue").unwrap(), Color::LightBlue);
1116 assert_eq!(Color::from_str("DARKGRAY").unwrap(), Color::DarkGray);
1117 assert_eq!(Color::from_str("grey").unwrap(), Color::DarkGray);
1118 assert_eq!(Color::from_str("reset").unwrap(), Color::Reset);
1119 assert_eq!(Color::from_str("default").unwrap(), Color::Reset);
1120 }
1121
1122 #[test]
1123 fn from_str_error_cases() {
1124 assert_eq!(
1126 Color::from_str("#ff6b").unwrap_err(),
1127 ColorParseError::InvalidLength
1128 );
1129 assert_eq!(
1131 Color::from_str("#zz0011").unwrap_err(),
1132 ColorParseError::InvalidHexDigit
1133 );
1134 assert_eq!(
1136 Color::from_str("#xyz").unwrap_err(),
1137 ColorParseError::InvalidHexDigit
1138 );
1139 assert_eq!(
1141 Color::from_str("nope").unwrap_err(),
1142 ColorParseError::Unknown
1143 );
1144 assert_eq!(Color::from_str("").unwrap_err(), ColorParseError::Unknown);
1145 }
1146
1147 #[test]
1148 fn color_parse_error_display_and_error_trait() {
1149 let e = ColorParseError::InvalidLength;
1151 assert!(!e.to_string().is_empty());
1152 let _: &dyn std::error::Error = &e;
1153 }
1154
1155 #[test]
1156 fn from_hsl_primaries() {
1157 assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1158 assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
1159 assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1160 assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
1162 assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255));
1163 assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128));
1165 }
1166
1167 #[test]
1168 fn from_hsl_wraps_and_clamps() {
1169 assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1171 assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1173 assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255));
1175 }
1176
1177 #[test]
1178 fn from_hsv_primaries() {
1179 assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
1180 assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
1181 assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
1182 assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
1184 assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
1185 }
1186
1187 #[test]
1188 fn rotate_hue_primary_round_trip() {
1189 assert_eq!(
1191 Color::Rgb(255, 0, 0).rotate_hue(120.0),
1192 Color::Rgb(0, 255, 0)
1193 );
1194 assert_eq!(
1195 Color::Rgb(0, 255, 0).rotate_hue(120.0),
1196 Color::Rgb(0, 0, 255)
1197 );
1198 assert_eq!(
1200 Color::Rgb(255, 0, 0).rotate_hue(180.0),
1201 Color::Rgb(0, 255, 255)
1202 );
1203 assert_eq!(
1205 Color::Rgb(255, 0, 0).rotate_hue(360.0),
1206 Color::Rgb(255, 0, 0)
1207 );
1208 }
1209
1210 #[test]
1211 fn rotate_hue_resolves_named_to_rgb() {
1212 let rotated = Color::Red.rotate_hue(0.0);
1214 assert_eq!(rotated, Color::Rgb(205, 49, 49));
1215 let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0);
1216 assert_eq!(gray, Color::Rgb(120, 120, 120));
1218 }
1219}