1use std::collections::HashMap;
4
5use ftui_render::cell::PackedRgba;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum ColorProfile {
10 Mono,
12 Ansi16,
14 Ansi256,
16 TrueColor,
18}
19
20impl ColorProfile {
21 #[must_use]
39 pub fn detect() -> Self {
40 Self::detect_from_env(
41 std::env::var("NO_COLOR").ok().as_deref(),
42 std::env::var("COLORTERM").ok().as_deref(),
43 std::env::var("TERM").ok().as_deref(),
44 )
45 }
46
47 #[must_use]
51 pub fn detect_from_env(
52 no_color: Option<&str>,
53 colorterm: Option<&str>,
54 term: Option<&str>,
55 ) -> Self {
56 if no_color.is_some() {
58 return Self::Mono;
59 }
60
61 if let Some(ct) = colorterm
63 && (ct == "truecolor" || ct == "24bit")
64 {
65 return Self::TrueColor;
66 }
67
68 if let Some(t) = term
70 && t.contains("256")
71 {
72 return Self::Ansi256;
73 }
74
75 Self::Ansi16
76 }
77
78 #[must_use]
82 pub const fn from_flags(true_color: bool, colors_256: bool, no_color: bool) -> Self {
83 if no_color {
84 Self::Mono
85 } else if true_color {
86 Self::TrueColor
87 } else if colors_256 {
88 Self::Ansi256
89 } else {
90 Self::Ansi16
91 }
92 }
93
94 #[must_use]
96 pub const fn supports_true_color(self) -> bool {
97 matches!(self, Self::TrueColor)
98 }
99
100 #[must_use]
102 pub const fn supports_256_colors(self) -> bool {
103 matches!(self, Self::TrueColor | Self::Ansi256)
104 }
105
106 #[must_use]
108 pub const fn supports_color(self) -> bool {
109 !matches!(self, Self::Mono)
110 }
111}
112
113pub const WCAG_AA_NORMAL_TEXT: f64 = 4.5;
119
120pub const WCAG_AA_LARGE_TEXT: f64 = 3.0;
122
123pub const WCAG_AAA_NORMAL_TEXT: f64 = 7.0;
125
126pub const WCAG_AAA_LARGE_TEXT: f64 = 4.5;
128
129#[inline]
133fn srgb_to_linear(c: f64) -> f64 {
134 if c <= 0.04045 {
135 c / 12.92
136 } else {
137 ((c + 0.055) / 1.055).powf(2.4)
138 }
139}
140
141#[must_use]
149pub fn relative_luminance(rgb: Rgb) -> f64 {
150 let r = srgb_to_linear(rgb.r as f64 / 255.0);
151 let g = srgb_to_linear(rgb.g as f64 / 255.0);
152 let b = srgb_to_linear(rgb.b as f64 / 255.0);
153 0.2126 * r + 0.7152 * g + 0.0722 * b
154}
155
156#[must_use]
158pub fn relative_luminance_packed(color: PackedRgba) -> f64 {
159 relative_luminance(Rgb::from(color))
160}
161
162#[must_use]
170pub fn contrast_ratio(fg: Rgb, bg: Rgb) -> f64 {
171 let lum_fg = relative_luminance(fg);
172 let lum_bg = relative_luminance(bg);
173 let lighter = lum_fg.max(lum_bg);
174 let darker = lum_fg.min(lum_bg);
175 (lighter + 0.05) / (darker + 0.05)
176}
177
178#[must_use]
180pub fn contrast_ratio_packed(fg: PackedRgba, bg: PackedRgba) -> f64 {
181 contrast_ratio(Rgb::from(fg), Rgb::from(bg))
182}
183
184#[must_use]
186pub fn meets_wcag_aa(fg: Rgb, bg: Rgb) -> bool {
187 contrast_ratio(fg, bg) >= WCAG_AA_NORMAL_TEXT
188}
189
190#[must_use]
192pub fn meets_wcag_aa_packed(fg: PackedRgba, bg: PackedRgba) -> bool {
193 contrast_ratio_packed(fg, bg) >= WCAG_AA_NORMAL_TEXT
194}
195
196#[must_use]
198pub fn meets_wcag_aa_large_text(fg: Rgb, bg: Rgb) -> bool {
199 contrast_ratio(fg, bg) >= WCAG_AA_LARGE_TEXT
200}
201
202#[must_use]
204pub fn meets_wcag_aaa(fg: Rgb, bg: Rgb) -> bool {
205 contrast_ratio(fg, bg) >= WCAG_AAA_NORMAL_TEXT
206}
207
208#[must_use]
212pub fn best_text_color(bg: Rgb, candidates: &[Rgb]) -> Rgb {
213 assert!(!candidates.is_empty(), "candidates must not be empty");
214
215 let mut best = candidates[0];
216 let mut best_ratio = contrast_ratio(best, bg);
217
218 for &candidate in candidates.iter().skip(1) {
219 let ratio = contrast_ratio(candidate, bg);
220 if ratio > best_ratio {
221 best = candidate;
222 best_ratio = ratio;
223 }
224 }
225
226 best
227}
228
229#[must_use]
231pub fn best_text_color_packed(bg: PackedRgba, candidates: &[PackedRgba]) -> PackedRgba {
232 assert!(!candidates.is_empty(), "candidates must not be empty");
233
234 let mut best = candidates[0];
235 let mut best_ratio = contrast_ratio_packed(best, bg);
236
237 for &candidate in candidates.iter().skip(1) {
238 let ratio = contrast_ratio_packed(candidate, bg);
239 if ratio > best_ratio {
240 best = candidate;
241 best_ratio = ratio;
242 }
243 }
244
245 best
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
254pub struct Rgb {
255 pub r: u8,
257 pub g: u8,
259 pub b: u8,
261}
262
263impl Rgb {
264 #[must_use]
266 pub const fn new(r: u8, g: u8, b: u8) -> Self {
267 Self { r, g, b }
268 }
269
270 #[must_use]
272 pub const fn as_key(self) -> u32 {
273 ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32)
274 }
275
276 #[must_use]
278 pub fn luminance_u8(self) -> u8 {
279 let r = self.r as u32;
281 let g = self.g as u32;
282 let b = self.b as u32;
283 let luma = 2126 * r + 7152 * g + 722 * b;
284 ((luma + 5000) / 10_000) as u8
285 }
286}
287
288impl From<PackedRgba> for Rgb {
289 fn from(color: PackedRgba) -> Self {
290 Self::new(color.r(), color.g(), color.b())
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
296#[repr(u8)]
297pub enum Ansi16 {
298 Black = 0,
300 Red = 1,
302 Green = 2,
304 Yellow = 3,
306 Blue = 4,
308 Magenta = 5,
310 Cyan = 6,
312 White = 7,
314 BrightBlack = 8,
316 BrightRed = 9,
318 BrightGreen = 10,
320 BrightYellow = 11,
322 BrightBlue = 12,
324 BrightMagenta = 13,
326 BrightCyan = 14,
328 BrightWhite = 15,
330}
331
332impl Ansi16 {
333 #[must_use]
335 pub const fn as_u8(self) -> u8 {
336 self as u8
337 }
338
339 #[must_use]
341 pub const fn from_u8(value: u8) -> Option<Self> {
342 match value {
343 0 => Some(Self::Black),
344 1 => Some(Self::Red),
345 2 => Some(Self::Green),
346 3 => Some(Self::Yellow),
347 4 => Some(Self::Blue),
348 5 => Some(Self::Magenta),
349 6 => Some(Self::Cyan),
350 7 => Some(Self::White),
351 8 => Some(Self::BrightBlack),
352 9 => Some(Self::BrightRed),
353 10 => Some(Self::BrightGreen),
354 11 => Some(Self::BrightYellow),
355 12 => Some(Self::BrightBlue),
356 13 => Some(Self::BrightMagenta),
357 14 => Some(Self::BrightCyan),
358 15 => Some(Self::BrightWhite),
359 _ => None,
360 }
361 }
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
366pub enum MonoColor {
367 Black,
369 White,
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
375pub enum Color {
376 Rgb(Rgb),
378 Ansi256(u8),
380 Ansi16(Ansi16),
382 Mono(MonoColor),
384}
385
386impl Color {
387 #[must_use]
389 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
390 Self::Rgb(Rgb::new(r, g, b))
391 }
392
393 #[must_use]
395 pub fn to_rgb(self) -> Rgb {
396 match self {
397 Self::Rgb(rgb) => rgb,
398 Self::Ansi256(idx) => ansi256_to_rgb(idx),
399 Self::Ansi16(color) => ansi16_to_rgb(color),
400 Self::Mono(MonoColor::Black) => Rgb::new(0, 0, 0),
401 Self::Mono(MonoColor::White) => Rgb::new(255, 255, 255),
402 }
403 }
404
405 #[must_use]
407 pub fn downgrade(self, profile: ColorProfile) -> Self {
408 match profile {
409 ColorProfile::TrueColor => self,
410 ColorProfile::Ansi256 => match self {
411 Self::Rgb(rgb) => Self::Ansi256(rgb_to_256(rgb.r, rgb.g, rgb.b)),
412 _ => self,
413 },
414 ColorProfile::Ansi16 => match self {
415 Self::Rgb(rgb) => Self::Ansi16(rgb_to_ansi16(rgb.r, rgb.g, rgb.b)),
416 Self::Ansi256(idx) => Self::Ansi16(rgb_to_ansi16_from_ansi256(idx)),
417 _ => self,
418 },
419 ColorProfile::Mono => match self {
420 Self::Rgb(rgb) => Self::Mono(rgb_to_mono(rgb.r, rgb.g, rgb.b)),
421 Self::Ansi256(idx) => {
422 let rgb = ansi256_to_rgb(idx);
423 Self::Mono(rgb_to_mono(rgb.r, rgb.g, rgb.b))
424 }
425 Self::Ansi16(color) => {
426 let rgb = ansi16_to_rgb(color);
427 Self::Mono(rgb_to_mono(rgb.r, rgb.g, rgb.b))
428 }
429 Self::Mono(_) => self,
430 },
431 }
432 }
433}
434
435impl From<PackedRgba> for Color {
436 fn from(color: PackedRgba) -> Self {
437 Self::Rgb(Rgb::from(color))
438 }
439}
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub struct CacheStats {
444 pub hits: u64,
446 pub misses: u64,
448 pub size: usize,
450 pub capacity: usize,
452}
453
454#[derive(Debug)]
456pub struct ColorCache {
457 profile: ColorProfile,
458 max_entries: usize,
459 map: HashMap<u32, Color>,
460 hits: u64,
461 misses: u64,
462}
463
464impl ColorCache {
465 #[must_use]
467 pub fn new(profile: ColorProfile) -> Self {
468 Self::with_capacity(profile, 4096)
469 }
470
471 #[must_use]
473 pub fn with_capacity(profile: ColorProfile, max_entries: usize) -> Self {
474 let max_entries = max_entries.max(1);
475 Self {
476 profile,
477 max_entries,
478 map: HashMap::with_capacity(max_entries.min(2048)),
479 hits: 0,
480 misses: 0,
481 }
482 }
483
484 #[must_use]
486 pub fn downgrade_rgb(&mut self, rgb: Rgb) -> Color {
487 let key = rgb.as_key();
488 if let Some(cached) = self.map.get(&key) {
489 self.hits += 1;
490 return *cached;
491 }
492 self.misses += 1;
493 let downgraded = Color::Rgb(rgb).downgrade(self.profile);
494 if self.map.len() >= self.max_entries {
495 self.map.clear();
496 }
497 self.map.insert(key, downgraded);
498 downgraded
499 }
500
501 #[must_use]
503 pub fn downgrade_packed(&mut self, color: PackedRgba) -> Color {
504 self.downgrade_rgb(Rgb::from(color))
505 }
506
507 #[must_use]
509 pub fn stats(&self) -> CacheStats {
510 CacheStats {
511 hits: self.hits,
512 misses: self.misses,
513 size: self.map.len(),
514 capacity: self.max_entries,
515 }
516 }
517}
518
519const ANSI16_PALETTE: [Rgb; 16] = [
520 Rgb::new(0, 0, 0), Rgb::new(205, 0, 0), Rgb::new(0, 205, 0), Rgb::new(205, 205, 0), Rgb::new(0, 0, 238), Rgb::new(205, 0, 205), Rgb::new(0, 205, 205), Rgb::new(229, 229, 229), Rgb::new(127, 127, 127), Rgb::new(255, 0, 0), Rgb::new(0, 255, 0), Rgb::new(255, 255, 0), Rgb::new(92, 92, 255), Rgb::new(255, 0, 255), Rgb::new(0, 255, 255), Rgb::new(255, 255, 255), ];
537
538#[must_use]
540pub fn ansi16_to_rgb(color: Ansi16) -> Rgb {
541 ANSI16_PALETTE[color.as_u8() as usize]
542}
543
544#[must_use]
546pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
547 let cube_idx = 16 + 36 * cube_index(r) + 6 * cube_index(g) + cube_index(b);
548
549 if r == g && g == b {
550 if r < 8 {
551 return 16;
552 }
553 if r > 246 {
554 return 231;
555 }
556 let gray_idx = 232 + ((r - 8) / 10).min(23);
557
558 let target = Rgb::new(r, g, b);
560 let cube_dist = weighted_distance(target, ansi256_to_rgb(cube_idx));
561 let gray_dist = weighted_distance(target, ansi256_to_rgb(gray_idx));
562
563 if cube_dist <= gray_dist {
564 return cube_idx;
565 } else {
566 return gray_idx;
567 }
568 }
569
570 cube_idx
571}
572
573fn cube_index(v: u8) -> u8 {
580 if v < 48 {
581 0
582 } else if v < 115 {
583 1
584 } else {
585 (v - 35) / 40
586 }
587}
588
589#[must_use]
591pub fn ansi256_to_rgb(index: u8) -> Rgb {
592 if index < 16 {
593 return ANSI16_PALETTE[index as usize];
594 }
595 if index >= 232 {
596 let gray = 8 + 10 * (index - 232);
597 return Rgb::new(gray, gray, gray);
598 }
599 let idx = index - 16;
600 let r = idx / 36;
601 let g = (idx / 6) % 6;
602 let b = idx % 6;
603 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
604 Rgb::new(LEVELS[r as usize], LEVELS[g as usize], LEVELS[b as usize])
605}
606
607#[must_use]
609pub fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Ansi16 {
610 let target = Rgb::new(r, g, b);
611 let mut best = Ansi16::Black;
612 let mut best_dist = u64::MAX;
613
614 for (idx, candidate) in ANSI16_PALETTE.iter().enumerate() {
615 let dist = weighted_distance(target, *candidate);
616 if dist < best_dist {
617 best = Ansi16::from_u8(idx as u8).unwrap_or(Ansi16::Black);
618 best_dist = dist;
619 }
620 }
621
622 best
623}
624
625#[must_use]
627pub fn rgb_to_ansi16_from_ansi256(index: u8) -> Ansi16 {
628 let rgb = ansi256_to_rgb(index);
629 rgb_to_ansi16(rgb.r, rgb.g, rgb.b)
630}
631
632#[must_use]
634pub fn rgb_to_mono(r: u8, g: u8, b: u8) -> MonoColor {
635 let luma = Rgb::new(r, g, b).luminance_u8();
636 if luma >= 128 {
637 MonoColor::White
638 } else {
639 MonoColor::Black
640 }
641}
642
643fn weighted_distance(a: Rgb, b: Rgb) -> u64 {
644 let dr = a.r as i32 - b.r as i32;
645 let dg = a.g as i32 - b.g as i32;
646 let db = a.b as i32 - b.b as i32;
647 let dr2 = (dr * dr) as u64;
648 let dg2 = (dg * dg) as u64;
649 let db2 = (db * db) as u64;
650 2126 * dr2 + 7152 * dg2 + 722 * db2
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
660 fn truecolor_passthrough() {
661 let color = Color::rgb(12, 34, 56);
662 assert_eq!(color.downgrade(ColorProfile::TrueColor), color);
663 }
664
665 #[test]
666 fn profile_from_flags_prefers_mono() {
667 assert_eq!(
668 ColorProfile::from_flags(true, true, true),
669 ColorProfile::Mono
670 );
671 assert_eq!(
672 ColorProfile::from_flags(true, false, false),
673 ColorProfile::TrueColor
674 );
675 assert_eq!(
676 ColorProfile::from_flags(false, true, false),
677 ColorProfile::Ansi256
678 );
679 assert_eq!(
680 ColorProfile::from_flags(false, false, false),
681 ColorProfile::Ansi16
682 );
683 }
684
685 #[test]
686 fn supports_true_color() {
687 assert!(ColorProfile::TrueColor.supports_true_color());
688 assert!(!ColorProfile::Ansi256.supports_true_color());
689 assert!(!ColorProfile::Ansi16.supports_true_color());
690 assert!(!ColorProfile::Mono.supports_true_color());
691 }
692
693 #[test]
694 fn supports_256_colors() {
695 assert!(ColorProfile::TrueColor.supports_256_colors());
696 assert!(ColorProfile::Ansi256.supports_256_colors());
697 assert!(!ColorProfile::Ansi16.supports_256_colors());
698 assert!(!ColorProfile::Mono.supports_256_colors());
699 }
700
701 #[test]
702 fn supports_color() {
703 assert!(ColorProfile::TrueColor.supports_color());
704 assert!(ColorProfile::Ansi256.supports_color());
705 assert!(ColorProfile::Ansi16.supports_color());
706 assert!(!ColorProfile::Mono.supports_color());
707 }
708
709 #[test]
712 fn detect_no_color_gives_mono() {
713 assert_eq!(
715 ColorProfile::detect_from_env(Some("1"), None, None),
716 ColorProfile::Mono
717 );
718 assert_eq!(
719 ColorProfile::detect_from_env(Some(""), None, None),
720 ColorProfile::Mono
721 );
722 assert_eq!(
724 ColorProfile::detect_from_env(Some("1"), Some("truecolor"), Some("xterm-256color")),
725 ColorProfile::Mono
726 );
727 }
728
729 #[test]
730 fn detect_colorterm_truecolor() {
731 assert_eq!(
732 ColorProfile::detect_from_env(None, Some("truecolor"), None),
733 ColorProfile::TrueColor
734 );
735 }
736
737 #[test]
738 fn detect_colorterm_24bit() {
739 assert_eq!(
740 ColorProfile::detect_from_env(None, Some("24bit"), None),
741 ColorProfile::TrueColor
742 );
743 }
744
745 #[test]
746 fn detect_term_256color() {
747 assert_eq!(
748 ColorProfile::detect_from_env(None, None, Some("xterm-256color")),
749 ColorProfile::Ansi256
750 );
751 assert_eq!(
752 ColorProfile::detect_from_env(None, None, Some("screen-256color")),
753 ColorProfile::Ansi256
754 );
755 }
756
757 #[test]
758 fn detect_colorterm_unknown_falls_to_term() {
759 assert_eq!(
761 ColorProfile::detect_from_env(None, Some("yes"), Some("xterm-256color")),
762 ColorProfile::Ansi256
763 );
764 }
765
766 #[test]
767 fn detect_defaults_to_ansi16() {
768 assert_eq!(
769 ColorProfile::detect_from_env(None, None, None),
770 ColorProfile::Ansi16
771 );
772 assert_eq!(
773 ColorProfile::detect_from_env(None, None, Some("xterm")),
774 ColorProfile::Ansi16
775 );
776 assert_eq!(
777 ColorProfile::detect_from_env(None, Some(""), Some("dumb")),
778 ColorProfile::Ansi16
779 );
780 }
781
782 #[test]
785 fn wcag_luminance_black_is_zero() {
786 let lum = relative_luminance(Rgb::new(0, 0, 0));
787 assert!((lum - 0.0).abs() < 0.001);
788 }
789
790 #[test]
791 fn wcag_luminance_white_is_one() {
792 let lum = relative_luminance(Rgb::new(255, 255, 255));
793 assert!((lum - 1.0).abs() < 0.001);
794 }
795
796 #[test]
797 fn wcag_luminance_green_is_brightest() {
798 let r_lum = relative_luminance(Rgb::new(255, 0, 0));
800 let g_lum = relative_luminance(Rgb::new(0, 255, 0));
801 let b_lum = relative_luminance(Rgb::new(0, 0, 255));
802 assert!(g_lum > r_lum);
803 assert!(g_lum > b_lum);
804 }
805
806 #[test]
807 fn contrast_ratio_black_white_is_21() {
808 let black = Rgb::new(0, 0, 0);
809 let white = Rgb::new(255, 255, 255);
810 let ratio = contrast_ratio(black, white);
811 assert!((ratio - 21.0).abs() < 0.01, "ratio was {}", ratio);
813 }
814
815 #[test]
816 fn contrast_ratio_is_symmetric() {
817 let a = Rgb::new(100, 150, 200);
818 let b = Rgb::new(50, 75, 100);
819 let ratio_ab = contrast_ratio(a, b);
820 let ratio_ba = contrast_ratio(b, a);
821 assert!((ratio_ab - ratio_ba).abs() < 0.001);
822 }
823
824 #[test]
825 fn contrast_ratio_same_color_is_one() {
826 let color = Rgb::new(128, 128, 128);
827 let ratio = contrast_ratio(color, color);
828 assert!((ratio - 1.0).abs() < 0.001);
829 }
830
831 #[test]
832 fn meets_wcag_aa_black_white() {
833 let black = Rgb::new(0, 0, 0);
834 let white = Rgb::new(255, 255, 255);
835 assert!(meets_wcag_aa(black, white));
836 assert!(meets_wcag_aa(white, black));
837 }
838
839 #[test]
840 fn meets_wcag_aa_low_contrast_fails() {
841 let gray1 = Rgb::new(128, 128, 128);
843 let gray2 = Rgb::new(140, 140, 140);
844 assert!(!meets_wcag_aa(gray1, gray2));
845 }
846
847 #[test]
848 fn meets_wcag_aaa_black_white() {
849 let black = Rgb::new(0, 0, 0);
850 let white = Rgb::new(255, 255, 255);
851 assert!(meets_wcag_aaa(black, white));
852 }
853
854 #[test]
855 fn best_text_color_chooses_highest_contrast() {
856 let dark_bg = Rgb::new(30, 30, 30);
857 let candidates = [
858 Rgb::new(50, 50, 50), Rgb::new(255, 255, 255), Rgb::new(100, 100, 100), ];
862 let best = best_text_color(dark_bg, &candidates);
863 assert_eq!(best, Rgb::new(255, 255, 255));
864
865 let light_bg = Rgb::new(240, 240, 240);
866 let best_on_light = best_text_color(light_bg, &candidates);
867 assert_eq!(best_on_light, Rgb::new(50, 50, 50));
868 }
869
870 #[test]
871 fn wcag_constants_are_correct() {
872 assert!((WCAG_AA_NORMAL_TEXT - 4.5).abs() < 0.001);
873 assert!((WCAG_AA_LARGE_TEXT - 3.0).abs() < 0.001);
874 assert!((WCAG_AAA_NORMAL_TEXT - 7.0).abs() < 0.001);
875 assert!((WCAG_AAA_LARGE_TEXT - 4.5).abs() < 0.001);
876 }
877
878 #[test]
881 fn rgb_as_key_is_unique() {
882 let a = Rgb::new(1, 2, 3);
883 let b = Rgb::new(3, 2, 1);
884 assert_ne!(a.as_key(), b.as_key());
885 assert_eq!(a.as_key(), Rgb::new(1, 2, 3).as_key());
886 }
887
888 #[test]
889 fn rgb_luminance_black_is_zero() {
890 assert_eq!(Rgb::new(0, 0, 0).luminance_u8(), 0);
891 }
892
893 #[test]
894 fn rgb_luminance_white_is_255() {
895 assert_eq!(Rgb::new(255, 255, 255).luminance_u8(), 255);
896 }
897
898 #[test]
899 fn rgb_luminance_green_is_brightest_channel() {
900 let green_only = Rgb::new(0, 128, 0).luminance_u8();
902 let red_only = Rgb::new(128, 0, 0).luminance_u8();
903 let blue_only = Rgb::new(0, 0, 128).luminance_u8();
904 assert!(green_only > red_only);
905 assert!(green_only > blue_only);
906 }
907
908 #[test]
909 fn rgb_from_packed_rgba() {
910 let packed = PackedRgba::rgb(10, 20, 30);
911 let rgb: Rgb = packed.into();
912 assert_eq!(rgb, Rgb::new(10, 20, 30));
913 }
914
915 #[test]
916 fn relative_luminance_packed_ignores_alpha() {
917 let opaque = PackedRgba::rgba(10, 20, 30, 255);
918 let transparent = PackedRgba::rgba(10, 20, 30, 0);
919 let l1 = relative_luminance_packed(opaque);
920 let l2 = relative_luminance_packed(transparent);
921 assert!(
922 (l1 - l2).abs() < 1.0e-12,
923 "luminance should ignore alpha (l1={l1}, l2={l2})"
924 );
925 }
926
927 #[test]
930 fn ansi16_from_u8_valid_range() {
931 for i in 0..=15 {
932 assert!(Ansi16::from_u8(i).is_some());
933 }
934 }
935
936 #[test]
937 fn ansi16_from_u8_invalid() {
938 assert!(Ansi16::from_u8(16).is_none());
939 assert!(Ansi16::from_u8(255).is_none());
940 }
941
942 #[test]
943 fn ansi16_round_trip() {
944 for i in 0..=15 {
945 let color = Ansi16::from_u8(i).unwrap();
946 assert_eq!(color.as_u8(), i);
947 }
948 }
949
950 #[test]
953 fn rgb_to_256_grayscale_rules() {
954 assert_eq!(rgb_to_256(0, 0, 0), 16);
955 assert_eq!(rgb_to_256(8, 8, 8), 232);
956 assert_eq!(rgb_to_256(18, 18, 18), 233);
957 assert_eq!(rgb_to_256(249, 249, 249), 231);
958 }
959
960 #[test]
961 fn rgb_to_256_primary_red() {
962 assert_eq!(rgb_to_256(255, 0, 0), 196);
963 }
964
965 #[test]
966 fn rgb_to_256_primary_green() {
967 assert_eq!(rgb_to_256(0, 255, 0), 46);
968 }
969
970 #[test]
971 fn rgb_to_256_primary_blue() {
972 assert_eq!(rgb_to_256(0, 0, 255), 21);
973 }
974
975 #[test]
978 fn ansi256_to_rgb_round_trip() {
979 let rgb = ansi256_to_rgb(196);
980 assert_eq!(rgb, Rgb::new(255, 0, 0));
981 }
982
983 #[test]
984 fn ansi256_to_rgb_first_16_match_palette() {
985 for i in 0..16 {
986 let rgb = ansi256_to_rgb(i);
987 assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
988 }
989 }
990
991 #[test]
992 fn ansi256_to_rgb_grayscale_ramp() {
993 let darkest = ansi256_to_rgb(232);
995 assert_eq!(darkest, Rgb::new(8, 8, 8));
996 let lightest = ansi256_to_rgb(255);
997 assert_eq!(lightest, Rgb::new(238, 238, 238));
998 }
999
1000 #[test]
1001 fn ansi256_color_cube_corners() {
1002 assert_eq!(ansi256_to_rgb(16), Rgb::new(0, 0, 0));
1004 assert_eq!(ansi256_to_rgb(231), Rgb::new(255, 255, 255));
1006 }
1007
1008 #[test]
1011 fn rgb_to_ansi16_basics() {
1012 assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1013 assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1014 assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1015 assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1016 }
1017
1018 #[test]
1019 fn rgb_to_ansi16_white() {
1020 assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1021 }
1022
1023 #[test]
1026 fn mono_fallback() {
1027 assert_eq!(rgb_to_mono(0, 0, 0), MonoColor::Black);
1028 assert_eq!(rgb_to_mono(255, 255, 255), MonoColor::White);
1029 assert_eq!(rgb_to_mono(200, 200, 200), MonoColor::White);
1030 assert_eq!(rgb_to_mono(30, 30, 30), MonoColor::Black);
1031 }
1032
1033 #[test]
1034 fn mono_boundary() {
1035 assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1037 assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1038 }
1039
1040 #[test]
1043 fn downgrade_rgb_to_ansi256() {
1044 let color = Color::rgb(255, 0, 0);
1045 let downgraded = color.downgrade(ColorProfile::Ansi256);
1046 assert!(matches!(downgraded, Color::Ansi256(_)));
1047 }
1048
1049 #[test]
1050 fn downgrade_rgb_to_ansi16() {
1051 let color = Color::rgb(255, 0, 0);
1052 let downgraded = color.downgrade(ColorProfile::Ansi16);
1053 assert!(matches!(downgraded, Color::Ansi16(_)));
1054 }
1055
1056 #[test]
1057 fn downgrade_rgb_to_mono() {
1058 let color = Color::rgb(255, 255, 255);
1059 let downgraded = color.downgrade(ColorProfile::Mono);
1060 assert_eq!(downgraded, Color::Mono(MonoColor::White));
1061 }
1062
1063 #[test]
1064 fn downgrade_ansi256_to_ansi16() {
1065 let color = Color::Ansi256(196);
1066 let downgraded = color.downgrade(ColorProfile::Ansi16);
1067 assert!(matches!(downgraded, Color::Ansi16(_)));
1068 }
1069
1070 #[test]
1071 fn downgrade_ansi256_to_mono() {
1072 let color = Color::Ansi256(232); let downgraded = color.downgrade(ColorProfile::Mono);
1074 assert_eq!(downgraded, Color::Mono(MonoColor::Black));
1075 }
1076
1077 #[test]
1078 fn downgrade_ansi16_to_mono() {
1079 let color = Color::Ansi16(Ansi16::BrightWhite);
1080 let downgraded = color.downgrade(ColorProfile::Mono);
1081 assert_eq!(downgraded, Color::Mono(MonoColor::White));
1082 }
1083
1084 #[test]
1085 fn downgrade_mono_stays_mono() {
1086 let color = Color::Mono(MonoColor::Black);
1087 assert_eq!(color.downgrade(ColorProfile::Mono), color);
1088 }
1089
1090 #[test]
1091 fn downgrade_ansi16_stays_at_ansi256() {
1092 let color = Color::Ansi16(Ansi16::Red);
1093 assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1095 }
1096
1097 #[test]
1100 fn color_to_rgb_all_variants() {
1101 assert_eq!(Color::rgb(1, 2, 3).to_rgb(), Rgb::new(1, 2, 3));
1102 assert_eq!(Color::Ansi256(196).to_rgb(), Rgb::new(255, 0, 0));
1103 assert_eq!(Color::Ansi16(Ansi16::Black).to_rgb(), Rgb::new(0, 0, 0));
1104 assert_eq!(
1105 Color::Mono(MonoColor::White).to_rgb(),
1106 Rgb::new(255, 255, 255)
1107 );
1108 assert_eq!(Color::Mono(MonoColor::Black).to_rgb(), Rgb::new(0, 0, 0));
1109 }
1110
1111 #[test]
1114 fn color_from_packed_rgba() {
1115 let packed = PackedRgba::rgb(42, 84, 126);
1116 let color: Color = packed.into();
1117 assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1118 }
1119
1120 #[test]
1121 fn color_from_packed_rgba_ignores_alpha() {
1122 let packed = PackedRgba::rgba(42, 84, 126, 10);
1123 let color: Color = packed.into();
1124 assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1125 }
1126
1127 #[test]
1130 fn cache_tracks_hits() {
1131 let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1132 let rgb = Rgb::new(10, 20, 30);
1133 let _ = cache.downgrade_rgb(rgb);
1134 let _ = cache.downgrade_rgb(rgb);
1135 let stats = cache.stats();
1136 assert_eq!(stats.hits, 1);
1137 assert_eq!(stats.misses, 1);
1138 assert_eq!(stats.size, 1);
1139 }
1140
1141 #[test]
1142 fn cache_clears_on_overflow() {
1143 let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 2);
1144 let _ = cache.downgrade_rgb(Rgb::new(1, 0, 0));
1145 let _ = cache.downgrade_rgb(Rgb::new(2, 0, 0));
1146 assert_eq!(cache.stats().size, 2);
1147 let _ = cache.downgrade_rgb(Rgb::new(3, 0, 0));
1149 assert_eq!(cache.stats().size, 1);
1150 }
1151
1152 #[test]
1153 fn cache_downgrade_packed() {
1154 let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1155 let packed = PackedRgba::rgb(255, 0, 0);
1156 let result = cache.downgrade_packed(packed);
1157 assert!(matches!(result, Color::Ansi16(_)));
1158 }
1159
1160 #[test]
1161 fn cache_default_capacity() {
1162 let cache = ColorCache::new(ColorProfile::TrueColor);
1163 assert_eq!(cache.stats().capacity, 4096);
1164 }
1165
1166 #[test]
1167 fn cache_minimum_capacity_is_one() {
1168 let cache = ColorCache::with_capacity(ColorProfile::Ansi16, 0);
1169 assert_eq!(cache.stats().capacity, 1);
1170 }
1171}
1172
1173#[cfg(test)]
1174mod downgrade_edge_cases {
1175 use super::*;
1181
1182 #[test]
1187 fn sequential_downgrade_truecolor_to_mono() {
1188 let white = Color::rgb(255, 255, 255);
1190 let black = Color::rgb(0, 0, 0);
1191
1192 let w256 = white.downgrade(ColorProfile::Ansi256);
1194 assert!(matches!(w256, Color::Ansi256(231))); let w16 = w256.downgrade(ColorProfile::Ansi16);
1196 assert!(matches!(w16, Color::Ansi16(Ansi16::BrightWhite)));
1197 let wmono = w16.downgrade(ColorProfile::Mono);
1198 assert_eq!(wmono, Color::Mono(MonoColor::White));
1199
1200 let b256 = black.downgrade(ColorProfile::Ansi256);
1202 assert!(matches!(b256, Color::Ansi256(16))); let b16 = b256.downgrade(ColorProfile::Ansi16);
1204 assert!(matches!(b16, Color::Ansi16(Ansi16::Black)));
1205 let bmono = b16.downgrade(ColorProfile::Mono);
1206 assert_eq!(bmono, Color::Mono(MonoColor::Black));
1207 }
1208
1209 #[test]
1210 fn sequential_downgrade_preserves_intent() {
1211 let red = Color::rgb(255, 0, 0);
1213
1214 let r256 = red.downgrade(ColorProfile::Ansi256);
1215 let Color::Ansi256(idx) = r256 else {
1216 panic!("Expected Ansi256");
1217 };
1218 assert_eq!(idx, 196); let r16 = r256.downgrade(ColorProfile::Ansi16);
1221 let Color::Ansi16(ansi) = r16 else {
1222 panic!("Expected Ansi16");
1223 };
1224 assert_eq!(ansi, Ansi16::BrightRed);
1226 }
1227
1228 #[test]
1233 fn rgb_to_256_grayscale_boundaries() {
1234 assert_eq!(rgb_to_256(0, 0, 0), 16);
1237 assert_eq!(rgb_to_256(7, 7, 7), 16);
1238
1239 assert_eq!(rgb_to_256(8, 8, 8), 232);
1241
1242 assert_eq!(rgb_to_256(247, 247, 247), 231);
1245 assert_eq!(rgb_to_256(248, 248, 248), 231);
1246 assert_eq!(rgb_to_256(249, 249, 249), 231);
1247 assert_eq!(rgb_to_256(255, 255, 255), 231);
1248
1249 assert_eq!(rgb_to_256(246, 246, 246), 255);
1251 }
1252
1253 #[test]
1254 fn rgb_to_256_grayscale_ramp_coverage() {
1255 for i in 0..24 {
1258 let gray_val = 8 + i * 10;
1259 let idx = rgb_to_256(gray_val, gray_val, gray_val);
1260 assert!(
1261 (232..=255).contains(&idx),
1262 "Gray {} mapped to {} (expected 232-255)",
1263 gray_val,
1264 idx
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn rgb_to_256_cube_corners() {
1271 assert_eq!(rgb_to_256(0, 0, 0), 16); assert_eq!(rgb_to_256(255, 0, 0), 196); assert_eq!(rgb_to_256(0, 255, 0), 46); assert_eq!(rgb_to_256(0, 0, 255), 21); assert_eq!(rgb_to_256(255, 255, 0), 226); assert_eq!(rgb_to_256(255, 0, 255), 201); assert_eq!(rgb_to_256(0, 255, 255), 51); assert_eq!(rgb_to_256(255, 255, 255), 231);
1281 }
1282
1283 #[test]
1284 fn rgb_to_256_non_gray_avoids_grayscale() {
1285 let idx = rgb_to_256(100, 100, 99);
1288 assert!(
1290 (16..=231).contains(&idx),
1291 "Non-gray {} should use cube",
1292 idx
1293 );
1294 }
1295
1296 #[test]
1301 fn cube_index_boundaries() {
1302 assert_eq!(super::cube_index(0), 0);
1305 assert_eq!(super::cube_index(47), 0);
1306 assert_eq!(super::cube_index(48), 1);
1307 assert_eq!(super::cube_index(114), 1);
1308 assert_eq!(super::cube_index(115), 2);
1309 assert_eq!(super::cube_index(155), 3);
1310 assert_eq!(super::cube_index(195), 4);
1311 assert_eq!(super::cube_index(235), 5);
1312 assert_eq!(super::cube_index(255), 5);
1313 }
1314
1315 #[test]
1320 fn ansi256_to_rgb_full_range() {
1321 for i in 0..=255 {
1323 let rgb = ansi256_to_rgb(i);
1324 let _ = (rgb.r, rgb.g, rgb.b);
1326 }
1327 }
1328
1329 #[test]
1330 fn ansi256_to_rgb_grayscale_range() {
1331 for i in 232..=255 {
1333 let rgb = ansi256_to_rgb(i);
1334 assert_eq!(rgb.r, rgb.g);
1335 assert_eq!(rgb.g, rgb.b);
1336 }
1337 }
1338
1339 #[test]
1340 fn ansi256_to_rgb_first_16_are_palette() {
1341 for i in 0..16 {
1343 let rgb = ansi256_to_rgb(i);
1344 assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
1345 }
1346 }
1347
1348 #[test]
1353 fn rgb_to_ansi16_pure_primaries() {
1354 assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1356 assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1357 assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1359 }
1360
1361 #[test]
1362 fn rgb_to_ansi16_grays() {
1363 assert_eq!(rgb_to_ansi16(127, 127, 127), Ansi16::BrightBlack);
1365 assert_eq!(rgb_to_ansi16(200, 200, 200), Ansi16::White);
1367 }
1368
1369 #[test]
1370 fn rgb_to_ansi16_extremes() {
1371 assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1373 assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1374 }
1375
1376 #[test]
1381 fn rgb_to_mono_luminance_boundary() {
1382 assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1385 assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1386
1387 assert_eq!(rgb_to_mono(0, 180, 0), MonoColor::White);
1390 assert_eq!(rgb_to_mono(0, 178, 0), MonoColor::Black);
1391 }
1392
1393 #[test]
1394 fn rgb_to_mono_color_saturation_irrelevant() {
1395 assert_eq!(rgb_to_mono(255, 0, 0), MonoColor::Black);
1398 assert_eq!(rgb_to_mono(0, 255, 0), MonoColor::White);
1400 assert_eq!(rgb_to_mono(0, 0, 255), MonoColor::Black);
1402 }
1403
1404 #[test]
1409 fn downgrade_at_same_level_is_identity() {
1410 let ansi16 = Color::Ansi16(Ansi16::Red);
1412 assert_eq!(ansi16.downgrade(ColorProfile::Ansi16), ansi16);
1413
1414 let ansi256 = Color::Ansi256(100);
1415 assert_eq!(ansi256.downgrade(ColorProfile::Ansi256), ansi256);
1416
1417 let mono = Color::Mono(MonoColor::Black);
1418 assert_eq!(mono.downgrade(ColorProfile::Mono), mono);
1419
1420 let rgb = Color::rgb(1, 2, 3);
1421 assert_eq!(rgb.downgrade(ColorProfile::TrueColor), rgb);
1422 }
1423
1424 #[test]
1425 fn downgrade_ansi16_passes_through_ansi256() {
1426 let color = Color::Ansi16(Ansi16::Cyan);
1429 assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1430 }
1431
1432 #[test]
1433 fn downgrade_mono_passes_through_all() {
1434 let black = Color::Mono(MonoColor::Black);
1436 let white = Color::Mono(MonoColor::White);
1437
1438 assert_eq!(black.downgrade(ColorProfile::TrueColor), black);
1439 assert_eq!(black.downgrade(ColorProfile::Ansi256), black);
1440 assert_eq!(black.downgrade(ColorProfile::Ansi16), black);
1441 assert_eq!(black.downgrade(ColorProfile::Mono), black);
1442
1443 assert_eq!(white.downgrade(ColorProfile::TrueColor), white);
1444 assert_eq!(white.downgrade(ColorProfile::Ansi256), white);
1445 assert_eq!(white.downgrade(ColorProfile::Ansi16), white);
1446 assert_eq!(white.downgrade(ColorProfile::Mono), white);
1447 }
1448
1449 #[test]
1454 fn luminance_formula_correctness() {
1455 let r_luma = Rgb::new(255, 0, 0).luminance_u8();
1458 let g_luma = Rgb::new(0, 255, 0).luminance_u8();
1459 let b_luma = Rgb::new(0, 0, 255).luminance_u8();
1460
1461 assert!(
1463 (50..=58).contains(&r_luma),
1464 "Red luma {} not near 54",
1465 r_luma
1466 );
1467 assert!(
1469 (178..=186).contains(&g_luma),
1470 "Green luma {} not near 182",
1471 g_luma
1472 );
1473 assert!(
1475 (15..=22).contains(&b_luma),
1476 "Blue luma {} not near 18",
1477 b_luma
1478 );
1479
1480 let all = Rgb::new(255, 255, 255).luminance_u8();
1482 assert_eq!(all, 255);
1483 }
1484
1485 #[test]
1486 fn luminance_mid_values() {
1487 let mid_gray = Rgb::new(128, 128, 128).luminance_u8();
1489 assert!(
1491 (126..=130).contains(&mid_gray),
1492 "Mid gray luma {} not near 128",
1493 mid_gray
1494 );
1495 }
1496}