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 pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
303 let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
304 Color::Rgb(r, g, b)
305 }
306
307 pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
322 let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
323 Color::Rgb(r, g, b)
324 }
325
326 pub fn rotate_hue(self, degrees: f32) -> Color {
343 let (r, g, b) = self.to_rgb();
344 let (h, s, l) = rgb_to_hsl(r, g, b);
345 let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
346 Color::Rgb(nr, ng, nb)
347 }
348}
349
350impl From<(u8, u8, u8)> for Color {
351 fn from((r, g, b): (u8, u8, u8)) -> Color {
353 Color::Rgb(r, g, b)
354 }
355}
356
357impl From<[u8; 3]> for Color {
358 fn from([r, g, b]: [u8; 3]) -> Color {
360 Color::Rgb(r, g, b)
361 }
362}
363
364impl From<u32> for Color {
365 fn from(value: u32) -> Color {
377 let r = ((value >> 16) & 0xff) as u8;
378 let g = ((value >> 8) & 0xff) as u8;
379 let b = (value & 0xff) as u8;
380 Color::Rgb(r, g, b)
381 }
382}
383
384#[non_exhaustive]
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub enum ColorParseError {
400 InvalidLength,
403 InvalidHexDigit,
405 Unknown,
407}
408
409impl std::fmt::Display for ColorParseError {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 let msg = match self {
412 ColorParseError::InvalidLength => "invalid color: hex form must have 3 or 6 digits",
413 ColorParseError::InvalidHexDigit => "invalid color: non-hex digit in hex form",
414 ColorParseError::Unknown => {
415 "invalid color: expected #rgb/#rrggbb, rrggbb, or a named color"
416 }
417 };
418 f.write_str(msg)
419 }
420}
421
422impl std::error::Error for ColorParseError {}
423
424impl std::str::FromStr for Color {
425 type Err = ColorParseError;
426
427 fn from_str(s: &str) -> Result<Color, ColorParseError> {
452 let trimmed = s.trim();
453
454 if let Some(c) = named_color(trimmed) {
457 return Ok(c);
458 }
459
460 let had_hash = trimmed.starts_with('#');
461 let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
462
463 match hex.len() {
464 3 => {
465 let mut it = hex.chars().map(|c| c.to_digit(16));
466 let r = it
467 .next()
468 .flatten()
469 .ok_or(ColorParseError::InvalidHexDigit)?;
470 let g = it
471 .next()
472 .flatten()
473 .ok_or(ColorParseError::InvalidHexDigit)?;
474 let b = it
475 .next()
476 .flatten()
477 .ok_or(ColorParseError::InvalidHexDigit)?;
478 Ok(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
479 }
480 6 => {
481 let r = u8::from_str_radix(&hex[0..2], 16)
482 .map_err(|_| ColorParseError::InvalidHexDigit)?;
483 let g = u8::from_str_radix(&hex[2..4], 16)
484 .map_err(|_| ColorParseError::InvalidHexDigit)?;
485 let b = u8::from_str_radix(&hex[4..6], 16)
486 .map_err(|_| ColorParseError::InvalidHexDigit)?;
487 Ok(Color::Rgb(r, g, b))
488 }
489 _ if had_hash => Err(ColorParseError::InvalidLength),
493 _ => Err(ColorParseError::Unknown),
494 }
495 }
496}
497
498fn named_color(s: &str) -> Option<Color> {
503 let lower = s.to_ascii_lowercase();
504 Some(match lower.as_str() {
505 "reset" | "default" => Color::Reset,
506 "black" => Color::Black,
507 "red" => Color::Red,
508 "green" => Color::Green,
509 "yellow" => Color::Yellow,
510 "blue" => Color::Blue,
511 "magenta" => Color::Magenta,
512 "cyan" => Color::Cyan,
513 "white" => Color::White,
514 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
515 "lightred" => Color::LightRed,
516 "lightgreen" => Color::LightGreen,
517 "lightyellow" => Color::LightYellow,
518 "lightblue" => Color::LightBlue,
519 "lightmagenta" => Color::LightMagenta,
520 "lightcyan" => Color::LightCyan,
521 "lightwhite" => Color::LightWhite,
522 _ => return None,
523 })
524}
525
526fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
531 let h = wrap_hue(h);
532 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
533 let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
534 let m = l - c / 2.0;
535 let (r1, g1, b1) = hue_sextant(h, c, x);
536 (
537 round_channel(r1 + m),
538 round_channel(g1 + m),
539 round_channel(b1 + m),
540 )
541}
542
543fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
548 let h = wrap_hue(h);
549 let c = v * s;
550 let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
551 let m = v - c;
552 let (r1, g1, b1) = hue_sextant(h, c, x);
553 (
554 round_channel(r1 + m),
555 round_channel(g1 + m),
556 round_channel(b1 + m),
557 )
558}
559
560fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
563 let rf = r as f32 / 255.0;
564 let gf = g as f32 / 255.0;
565 let bf = b as f32 / 255.0;
566 let max = rf.max(gf).max(bf);
567 let min = rf.min(gf).min(bf);
568 let delta = max - min;
569 let l = (max + min) / 2.0;
570
571 if delta <= f32::EPSILON {
572 return (0.0, 0.0, l);
574 }
575
576 let s = if l > 0.5 {
577 delta / (2.0 - max - min)
578 } else {
579 delta / (max + min)
580 };
581
582 let h = if max == rf {
583 let h = (gf - bf) / delta;
584 h % 6.0
585 } else if max == gf {
586 (bf - rf) / delta + 2.0
587 } else {
588 (rf - gf) / delta + 4.0
589 } * 60.0;
590
591 (wrap_hue(h), s, l)
592}
593
594#[inline]
597fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
598 match h {
599 h if h < 60.0 => (c, x, 0.0),
600 h if h < 120.0 => (x, c, 0.0),
601 h if h < 180.0 => (0.0, c, x),
602 h if h < 240.0 => (0.0, x, c),
603 h if h < 300.0 => (x, 0.0, c),
604 _ => (c, 0.0, x),
605 }
606}
607
608#[inline]
610fn wrap_hue(h: f32) -> f32 {
611 let h = h % 360.0;
612 if h < 0.0 {
613 h + 360.0
614 } else {
615 h
616 }
617}
618
619#[inline]
621fn round_channel(v: f32) -> u8 {
622 (v * 255.0).round().clamp(0.0, 255.0) as u8
623}
624
625#[cfg(feature = "serde")]
626impl Color {
627 fn named_token(self) -> Option<&'static str> {
629 Some(match self {
630 Color::Reset => "reset",
631 Color::Black => "black",
632 Color::Red => "red",
633 Color::Green => "green",
634 Color::Yellow => "yellow",
635 Color::Blue => "blue",
636 Color::Magenta => "magenta",
637 Color::Cyan => "cyan",
638 Color::White => "white",
639 Color::DarkGray => "darkgray",
640 Color::LightRed => "lightred",
641 Color::LightGreen => "lightgreen",
642 Color::LightYellow => "lightyellow",
643 Color::LightBlue => "lightblue",
644 Color::LightMagenta => "lightmagenta",
645 Color::LightCyan => "lightcyan",
646 Color::LightWhite => "lightwhite",
647 Color::Rgb(..) | Color::Indexed(_) => return None,
648 })
649 }
650
651 fn from_token(s: &str) -> Option<Color> {
657 if let Some(c) = Color::from_hex(s) {
658 return Some(c);
659 }
660 let lower = s.trim().to_ascii_lowercase();
661 if let Some(rest) = lower.strip_prefix("indexed:") {
662 return rest.trim().parse::<u8>().ok().map(Color::Indexed);
663 }
664 Some(match lower.as_str() {
665 "reset" | "default" => Color::Reset,
666 "black" => Color::Black,
667 "red" => Color::Red,
668 "green" => Color::Green,
669 "yellow" => Color::Yellow,
670 "blue" => Color::Blue,
671 "magenta" => Color::Magenta,
672 "cyan" => Color::Cyan,
673 "white" => Color::White,
674 "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
675 "lightred" => Color::LightRed,
676 "lightgreen" => Color::LightGreen,
677 "lightyellow" => Color::LightYellow,
678 "lightblue" => Color::LightBlue,
679 "lightmagenta" => Color::LightMagenta,
680 "lightcyan" => Color::LightCyan,
681 "lightwhite" => Color::LightWhite,
682 _ => return None,
683 })
684 }
685
686 fn to_token(self) -> String {
691 if let Some(name) = self.named_token() {
692 return name.to_string();
693 }
694 match self {
695 Color::Indexed(n) => format!("indexed:{n}"),
696 other => other.to_hex(),
698 }
699 }
700}
701
702#[cfg(feature = "serde")]
703impl serde::Serialize for Color {
704 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
707 where
708 S: serde::Serializer,
709 {
710 serializer.serialize_str(&self.to_token())
711 }
712}
713
714#[cfg(feature = "serde")]
715impl<'de> serde::Deserialize<'de> for Color {
716 fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
719 where
720 D: serde::Deserializer<'de>,
721 {
722 struct ColorVisitor;
723
724 impl serde::de::Visitor<'_> for ColorVisitor {
725 type Value = Color;
726
727 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
728 f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
729 }
730
731 fn visit_str<E>(self, value: &str) -> Result<Color, E>
732 where
733 E: serde::de::Error,
734 {
735 Color::from_token(value).ok_or_else(|| {
736 E::custom(format!(
737 "invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
738 ))
739 })
740 }
741 }
742
743 deserializer.deserialize_str(ColorVisitor)
744 }
745}
746
747#[non_exhaustive]
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
754#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
755pub enum ColorDepth {
756 TrueColor,
758 EightBit,
760 Basic,
762 NoColor,
767}
768
769#[cfg(test)]
770mod color_depth_tests {
771 use super::{Color, ColorDepth};
772
773 #[test]
774 fn no_color_downsamples_everything_to_reset() {
775 assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
776 assert_eq!(
777 Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
778 Color::Reset
779 );
780 assert_eq!(
781 Color::Indexed(44).downsampled(ColorDepth::NoColor),
782 Color::Reset
783 );
784 }
785}
786
787impl ColorDepth {
788 pub fn detect() -> Self {
796 if std::env::var("NO_COLOR")
798 .ok()
799 .is_some_and(|v| !v.is_empty())
800 {
801 return Self::NoColor;
802 }
803 if let Ok(ct) = std::env::var("COLORTERM") {
804 let ct = ct.to_lowercase();
805 if ct == "truecolor" || ct == "24bit" {
806 return Self::TrueColor;
807 }
808 }
809 if let Ok(term) = std::env::var("TERM") {
810 if term.contains("256color") {
811 return Self::EightBit;
812 }
813 }
814 Self::Basic
815 }
816}
817
818fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
819 if r == g && g == b {
820 if r < 8 {
821 return 16;
822 }
823 if r >= 248 {
824 return 231;
825 }
826 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
827 }
828
829 let ri = if r < 48 {
830 0
831 } else {
832 ((r as u16 - 35) / 40) as u8
833 };
834 let gi = if g < 48 {
835 0
836 } else {
837 ((g as u16 - 35) / 40) as u8
838 };
839 let bi = if b < 48 {
840 0
841 } else {
842 ((b as u16 - 35) / 40) as u8
843 };
844 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
845}
846
847fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
848 let lum = 0.2126 * to_linear(r as f32 / 255.0)
849 + 0.7152 * to_linear(g as f32 / 255.0)
850 + 0.0722 * to_linear(b as f32 / 255.0);
851
852 let max = r.max(g).max(b);
853 let min = r.min(g).min(b);
854 let saturation = if max == 0 {
855 0.0
856 } else {
857 (max - min) as f32 / max as f32
858 };
859
860 if saturation < 0.2 {
861 return match lum {
863 l if l < 0.05 => Color::Black,
864 l if l < 0.25 => Color::DarkGray,
865 l if l < 0.7 => Color::White,
866 _ => Color::White, };
868 }
869
870 let bright = max >= 200 && min >= 64;
878
879 let rf = r as f32;
880 let gf = g as f32;
881 let bf = b as f32;
882
883 if rf >= gf && rf >= bf {
884 if gf > bf * 1.5 {
885 if bright {
886 Color::LightYellow
887 } else {
888 Color::Yellow
889 }
890 } else if bf > gf * 1.5 {
891 if bright {
892 Color::LightMagenta
893 } else {
894 Color::Magenta
895 }
896 } else if bright {
897 Color::LightRed
898 } else {
899 Color::Red
900 }
901 } else if gf >= rf && gf >= bf {
902 if bf > rf * 1.5 {
903 if bright {
904 Color::LightCyan
905 } else {
906 Color::Cyan
907 }
908 } else if bright {
909 Color::LightGreen
910 } else {
911 Color::Green
912 }
913 } else if rf > gf * 1.5 {
914 if bright {
915 Color::LightMagenta
916 } else {
917 Color::Magenta
918 }
919 } else if gf > rf * 1.5 {
920 if bright {
921 Color::LightCyan
922 } else {
923 Color::Cyan
924 }
925 } else if bright {
926 Color::LightBlue
927 } else {
928 Color::Blue
929 }
930}
931
932fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
933 match idx {
934 0 => (0, 0, 0),
935 1 => (128, 0, 0),
936 2 => (0, 128, 0),
937 3 => (128, 128, 0),
938 4 => (0, 0, 128),
939 5 => (128, 0, 128),
940 6 => (0, 128, 128),
941 7 => (192, 192, 192),
942 8 => (128, 128, 128),
943 9 => (255, 0, 0),
944 10 => (0, 255, 0),
945 11 => (255, 255, 0),
946 12 => (0, 0, 255),
947 13 => (255, 0, 255),
948 14 => (0, 255, 255),
949 15 => (255, 255, 255),
950 16..=231 => {
951 let n = idx - 16;
952 let b_idx = n % 6;
953 let g_idx = (n / 6) % 6;
954 let r_idx = n / 36;
955 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
956 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
957 }
958 232..=255 => {
959 let v = 8 + 10 * (idx - 232);
960 (v, v, v)
961 }
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 #![allow(clippy::unwrap_used)]
968 use super::*;
969
970 #[test]
971 fn blend_halfway_rounds_to_128() {
972 assert_eq!(
973 Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
974 Color::Rgb(128, 128, 128)
975 );
976 }
977
978 #[test]
979 fn contrast_ratio_white_on_black_is_high() {
980 let ratio = Color::contrast_ratio(Color::White, Color::Black);
981 assert!(ratio > 15.0);
982 }
983
984 #[test]
985 fn contrast_ratio_same_color_is_one() {
986 let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
987 assert!((ratio - 1.0).abs() < 0.01);
988 }
989
990 #[test]
991 fn meets_contrast_aa_white_on_black() {
992 assert!(Color::meets_contrast_aa(Color::White, Color::Black));
993 }
994
995 #[test]
996 fn meets_contrast_aa_low_contrast_fails() {
997 assert!(!Color::meets_contrast_aa(
998 Color::Rgb(180, 180, 180),
999 Color::Rgb(200, 200, 200)
1000 ));
1001 }
1002
1003 #[test]
1006 fn rgb_to_ansi256_no_overflow_full_range() {
1007 for r in 0u8..=255 {
1009 for g in 0u8..=255 {
1010 for b in 0u8..=255 {
1011 let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
1012 }
1013 }
1014 }
1015 }
1016
1017 #[test]
1018 fn rgb_248_maps_to_231() {
1019 assert_eq!(
1020 Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
1021 Color::Indexed(231)
1022 );
1023 }
1024
1025 #[test]
1028 fn luminance_dracula_purple_wcag() {
1029 let l = Color::Rgb(189, 147, 249).luminance();
1030 assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
1031 }
1032
1033 #[test]
1034 fn contrast_aa_dracula_pair() {
1035 let p = Color::Rgb(189, 147, 249);
1036 let bg = Color::Rgb(40, 42, 54);
1037 assert!(Color::meets_contrast_aa(p, bg));
1038 let r = Color::contrast_ratio(p, bg);
1039 assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
1040 }
1041
1042 #[test]
1043 fn contrast_white_on_black_is_21() {
1044 let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
1045 assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
1046 }
1047
1048 #[test]
1051 fn rgb_to_ansi16_bright_variants() {
1052 assert_eq!(
1054 Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
1055 Color::LightRed
1056 );
1057 assert_eq!(
1059 Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
1060 Color::Red
1061 );
1062 assert_eq!(
1064 Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
1065 Color::White
1066 );
1067 assert_eq!(
1069 Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
1070 Color::DarkGray
1071 );
1072 }
1073
1074 use std::str::FromStr;
1077
1078 #[test]
1079 fn from_tuple_and_array() {
1080 assert_eq!(Color::from((255, 107, 107)), Color::Rgb(255, 107, 107));
1081 assert_eq!(Color::from([1u8, 2, 3]), Color::Rgb(1, 2, 3));
1082 let c: Color = (10, 20, 30).into();
1084 assert_eq!(c, Color::Rgb(10, 20, 30));
1085 }
1086
1087 #[test]
1088 fn from_u32_packs_rrggbb() {
1089 assert_eq!(Color::from(0xff6b6b_u32), Color::Rgb(255, 107, 107));
1090 assert_eq!(Color::from(0x000000_u32), Color::Rgb(0, 0, 0));
1091 assert_eq!(Color::from(0xffffff_u32), Color::Rgb(255, 255, 255));
1092 assert_eq!(Color::from(0xff00ff00_u32), Color::Rgb(0, 255, 0));
1094 }
1095
1096 #[test]
1097 fn from_str_hex_round_trips() {
1098 assert_eq!(
1099 Color::from_str("#ff6b6b").unwrap(),
1100 Color::Rgb(255, 107, 107)
1101 );
1102 assert_eq!(
1104 Color::from_str("ff6b6b").unwrap(),
1105 Color::Rgb(255, 107, 107)
1106 );
1107 assert_eq!(Color::from_str("#abc").unwrap(), Color::Rgb(170, 187, 204));
1109 assert_eq!(Color::from_str("abc").unwrap(), Color::Rgb(170, 187, 204));
1110 assert_eq!(
1112 Color::from_str(" #ff6b6b ").unwrap(),
1113 Color::Rgb(255, 107, 107)
1114 );
1115 let c = Color::Rgb(18, 52, 86);
1117 assert_eq!(Color::from_str(&c.to_hex()).unwrap(), c);
1118 }
1119
1120 #[test]
1121 fn from_str_named_colors() {
1122 assert_eq!(Color::from_str("cyan").unwrap(), Color::Cyan);
1123 assert_eq!(Color::from_str("LightBlue").unwrap(), Color::LightBlue);
1124 assert_eq!(Color::from_str("DARKGRAY").unwrap(), Color::DarkGray);
1125 assert_eq!(Color::from_str("grey").unwrap(), Color::DarkGray);
1126 assert_eq!(Color::from_str("reset").unwrap(), Color::Reset);
1127 assert_eq!(Color::from_str("default").unwrap(), Color::Reset);
1128 }
1129
1130 #[test]
1131 fn from_str_error_cases() {
1132 assert_eq!(
1134 Color::from_str("#ff6b").unwrap_err(),
1135 ColorParseError::InvalidLength
1136 );
1137 assert_eq!(
1139 Color::from_str("#zz0011").unwrap_err(),
1140 ColorParseError::InvalidHexDigit
1141 );
1142 assert_eq!(
1144 Color::from_str("#xyz").unwrap_err(),
1145 ColorParseError::InvalidHexDigit
1146 );
1147 assert_eq!(
1149 Color::from_str("nope").unwrap_err(),
1150 ColorParseError::Unknown
1151 );
1152 assert_eq!(Color::from_str("").unwrap_err(), ColorParseError::Unknown);
1153 }
1154
1155 #[test]
1156 fn color_parse_error_display_and_error_trait() {
1157 let e = ColorParseError::InvalidLength;
1159 assert!(!e.to_string().is_empty());
1160 let _: &dyn std::error::Error = &e;
1161 }
1162
1163 #[test]
1164 fn from_hsl_primaries() {
1165 assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1166 assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
1167 assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1168 assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
1170 assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255));
1171 assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128));
1173 }
1174
1175 #[test]
1176 fn from_hsl_wraps_and_clamps() {
1177 assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
1179 assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
1181 assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255));
1183 }
1184
1185 #[test]
1186 fn from_hsv_primaries() {
1187 assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
1188 assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
1189 assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
1190 assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
1192 assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
1193 }
1194
1195 #[test]
1196 fn rotate_hue_primary_round_trip() {
1197 assert_eq!(
1199 Color::Rgb(255, 0, 0).rotate_hue(120.0),
1200 Color::Rgb(0, 255, 0)
1201 );
1202 assert_eq!(
1203 Color::Rgb(0, 255, 0).rotate_hue(120.0),
1204 Color::Rgb(0, 0, 255)
1205 );
1206 assert_eq!(
1208 Color::Rgb(255, 0, 0).rotate_hue(180.0),
1209 Color::Rgb(0, 255, 255)
1210 );
1211 assert_eq!(
1213 Color::Rgb(255, 0, 0).rotate_hue(360.0),
1214 Color::Rgb(255, 0, 0)
1215 );
1216 }
1217
1218 #[test]
1219 fn rotate_hue_resolves_named_to_rgb() {
1220 let rotated = Color::Red.rotate_hue(0.0);
1222 assert_eq!(rotated, Color::Rgb(205, 49, 49));
1223 let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0);
1224 assert_eq!(gray, Color::Rgb(120, 120, 120));
1226 }
1227}