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 if r == g && g == b {
548 if r < 8 {
549 return 16;
550 }
551 if r > 248 {
552 return 231;
553 }
554 let idx = ((r - 8) / 10).min(23);
555 return 232 + idx;
556 }
557
558 16 + 36 * cube_index(r) + 6 * cube_index(g) + cube_index(b)
559}
560
561fn cube_index(v: u8) -> u8 {
568 if v < 48 {
569 0
570 } else if v < 115 {
571 1
572 } else {
573 (v - 35) / 40
574 }
575}
576
577#[must_use]
579pub fn ansi256_to_rgb(index: u8) -> Rgb {
580 if index < 16 {
581 return ANSI16_PALETTE[index as usize];
582 }
583 if index >= 232 {
584 let gray = 8 + 10 * (index - 232);
585 return Rgb::new(gray, gray, gray);
586 }
587 let idx = index - 16;
588 let r = idx / 36;
589 let g = (idx / 6) % 6;
590 let b = idx % 6;
591 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
592 Rgb::new(LEVELS[r as usize], LEVELS[g as usize], LEVELS[b as usize])
593}
594
595#[must_use]
597pub fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Ansi16 {
598 let target = Rgb::new(r, g, b);
599 let mut best = Ansi16::Black;
600 let mut best_dist = u64::MAX;
601
602 for (idx, candidate) in ANSI16_PALETTE.iter().enumerate() {
603 let dist = weighted_distance(target, *candidate);
604 if dist < best_dist {
605 best = Ansi16::from_u8(idx as u8).unwrap_or(Ansi16::Black);
606 best_dist = dist;
607 }
608 }
609
610 best
611}
612
613#[must_use]
615pub fn rgb_to_ansi16_from_ansi256(index: u8) -> Ansi16 {
616 let rgb = ansi256_to_rgb(index);
617 rgb_to_ansi16(rgb.r, rgb.g, rgb.b)
618}
619
620#[must_use]
622pub fn rgb_to_mono(r: u8, g: u8, b: u8) -> MonoColor {
623 let luma = Rgb::new(r, g, b).luminance_u8();
624 if luma >= 128 {
625 MonoColor::White
626 } else {
627 MonoColor::Black
628 }
629}
630
631fn weighted_distance(a: Rgb, b: Rgb) -> u64 {
632 let dr = a.r as i32 - b.r as i32;
633 let dg = a.g as i32 - b.g as i32;
634 let db = a.b as i32 - b.b as i32;
635 let dr2 = (dr * dr) as u64;
636 let dg2 = (dg * dg) as u64;
637 let db2 = (db * db) as u64;
638 2126 * dr2 + 7152 * dg2 + 722 * db2
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
648 fn truecolor_passthrough() {
649 let color = Color::rgb(12, 34, 56);
650 assert_eq!(color.downgrade(ColorProfile::TrueColor), color);
651 }
652
653 #[test]
654 fn profile_from_flags_prefers_mono() {
655 assert_eq!(
656 ColorProfile::from_flags(true, true, true),
657 ColorProfile::Mono
658 );
659 assert_eq!(
660 ColorProfile::from_flags(true, false, false),
661 ColorProfile::TrueColor
662 );
663 assert_eq!(
664 ColorProfile::from_flags(false, true, false),
665 ColorProfile::Ansi256
666 );
667 assert_eq!(
668 ColorProfile::from_flags(false, false, false),
669 ColorProfile::Ansi16
670 );
671 }
672
673 #[test]
674 fn supports_true_color() {
675 assert!(ColorProfile::TrueColor.supports_true_color());
676 assert!(!ColorProfile::Ansi256.supports_true_color());
677 assert!(!ColorProfile::Ansi16.supports_true_color());
678 assert!(!ColorProfile::Mono.supports_true_color());
679 }
680
681 #[test]
682 fn supports_256_colors() {
683 assert!(ColorProfile::TrueColor.supports_256_colors());
684 assert!(ColorProfile::Ansi256.supports_256_colors());
685 assert!(!ColorProfile::Ansi16.supports_256_colors());
686 assert!(!ColorProfile::Mono.supports_256_colors());
687 }
688
689 #[test]
690 fn supports_color() {
691 assert!(ColorProfile::TrueColor.supports_color());
692 assert!(ColorProfile::Ansi256.supports_color());
693 assert!(ColorProfile::Ansi16.supports_color());
694 assert!(!ColorProfile::Mono.supports_color());
695 }
696
697 #[test]
700 fn detect_no_color_gives_mono() {
701 assert_eq!(
703 ColorProfile::detect_from_env(Some("1"), None, None),
704 ColorProfile::Mono
705 );
706 assert_eq!(
707 ColorProfile::detect_from_env(Some(""), None, None),
708 ColorProfile::Mono
709 );
710 assert_eq!(
712 ColorProfile::detect_from_env(Some("1"), Some("truecolor"), Some("xterm-256color")),
713 ColorProfile::Mono
714 );
715 }
716
717 #[test]
718 fn detect_colorterm_truecolor() {
719 assert_eq!(
720 ColorProfile::detect_from_env(None, Some("truecolor"), None),
721 ColorProfile::TrueColor
722 );
723 }
724
725 #[test]
726 fn detect_colorterm_24bit() {
727 assert_eq!(
728 ColorProfile::detect_from_env(None, Some("24bit"), None),
729 ColorProfile::TrueColor
730 );
731 }
732
733 #[test]
734 fn detect_term_256color() {
735 assert_eq!(
736 ColorProfile::detect_from_env(None, None, Some("xterm-256color")),
737 ColorProfile::Ansi256
738 );
739 assert_eq!(
740 ColorProfile::detect_from_env(None, None, Some("screen-256color")),
741 ColorProfile::Ansi256
742 );
743 }
744
745 #[test]
746 fn detect_colorterm_unknown_falls_to_term() {
747 assert_eq!(
749 ColorProfile::detect_from_env(None, Some("yes"), Some("xterm-256color")),
750 ColorProfile::Ansi256
751 );
752 }
753
754 #[test]
755 fn detect_defaults_to_ansi16() {
756 assert_eq!(
757 ColorProfile::detect_from_env(None, None, None),
758 ColorProfile::Ansi16
759 );
760 assert_eq!(
761 ColorProfile::detect_from_env(None, None, Some("xterm")),
762 ColorProfile::Ansi16
763 );
764 assert_eq!(
765 ColorProfile::detect_from_env(None, Some(""), Some("dumb")),
766 ColorProfile::Ansi16
767 );
768 }
769
770 #[test]
773 fn wcag_luminance_black_is_zero() {
774 let lum = relative_luminance(Rgb::new(0, 0, 0));
775 assert!((lum - 0.0).abs() < 0.001);
776 }
777
778 #[test]
779 fn wcag_luminance_white_is_one() {
780 let lum = relative_luminance(Rgb::new(255, 255, 255));
781 assert!((lum - 1.0).abs() < 0.001);
782 }
783
784 #[test]
785 fn wcag_luminance_green_is_brightest() {
786 let r_lum = relative_luminance(Rgb::new(255, 0, 0));
788 let g_lum = relative_luminance(Rgb::new(0, 255, 0));
789 let b_lum = relative_luminance(Rgb::new(0, 0, 255));
790 assert!(g_lum > r_lum);
791 assert!(g_lum > b_lum);
792 }
793
794 #[test]
795 fn contrast_ratio_black_white_is_21() {
796 let black = Rgb::new(0, 0, 0);
797 let white = Rgb::new(255, 255, 255);
798 let ratio = contrast_ratio(black, white);
799 assert!((ratio - 21.0).abs() < 0.01, "ratio was {}", ratio);
801 }
802
803 #[test]
804 fn contrast_ratio_is_symmetric() {
805 let a = Rgb::new(100, 150, 200);
806 let b = Rgb::new(50, 75, 100);
807 let ratio_ab = contrast_ratio(a, b);
808 let ratio_ba = contrast_ratio(b, a);
809 assert!((ratio_ab - ratio_ba).abs() < 0.001);
810 }
811
812 #[test]
813 fn contrast_ratio_same_color_is_one() {
814 let color = Rgb::new(128, 128, 128);
815 let ratio = contrast_ratio(color, color);
816 assert!((ratio - 1.0).abs() < 0.001);
817 }
818
819 #[test]
820 fn meets_wcag_aa_black_white() {
821 let black = Rgb::new(0, 0, 0);
822 let white = Rgb::new(255, 255, 255);
823 assert!(meets_wcag_aa(black, white));
824 assert!(meets_wcag_aa(white, black));
825 }
826
827 #[test]
828 fn meets_wcag_aa_low_contrast_fails() {
829 let gray1 = Rgb::new(128, 128, 128);
831 let gray2 = Rgb::new(140, 140, 140);
832 assert!(!meets_wcag_aa(gray1, gray2));
833 }
834
835 #[test]
836 fn meets_wcag_aaa_black_white() {
837 let black = Rgb::new(0, 0, 0);
838 let white = Rgb::new(255, 255, 255);
839 assert!(meets_wcag_aaa(black, white));
840 }
841
842 #[test]
843 fn best_text_color_chooses_highest_contrast() {
844 let dark_bg = Rgb::new(30, 30, 30);
845 let candidates = [
846 Rgb::new(50, 50, 50), Rgb::new(255, 255, 255), Rgb::new(100, 100, 100), ];
850 let best = best_text_color(dark_bg, &candidates);
851 assert_eq!(best, Rgb::new(255, 255, 255));
852
853 let light_bg = Rgb::new(240, 240, 240);
854 let best_on_light = best_text_color(light_bg, &candidates);
855 assert_eq!(best_on_light, Rgb::new(50, 50, 50));
856 }
857
858 #[test]
859 fn wcag_constants_are_correct() {
860 assert!((WCAG_AA_NORMAL_TEXT - 4.5).abs() < 0.001);
861 assert!((WCAG_AA_LARGE_TEXT - 3.0).abs() < 0.001);
862 assert!((WCAG_AAA_NORMAL_TEXT - 7.0).abs() < 0.001);
863 assert!((WCAG_AAA_LARGE_TEXT - 4.5).abs() < 0.001);
864 }
865
866 #[test]
869 fn rgb_as_key_is_unique() {
870 let a = Rgb::new(1, 2, 3);
871 let b = Rgb::new(3, 2, 1);
872 assert_ne!(a.as_key(), b.as_key());
873 assert_eq!(a.as_key(), Rgb::new(1, 2, 3).as_key());
874 }
875
876 #[test]
877 fn rgb_luminance_black_is_zero() {
878 assert_eq!(Rgb::new(0, 0, 0).luminance_u8(), 0);
879 }
880
881 #[test]
882 fn rgb_luminance_white_is_255() {
883 assert_eq!(Rgb::new(255, 255, 255).luminance_u8(), 255);
884 }
885
886 #[test]
887 fn rgb_luminance_green_is_brightest_channel() {
888 let green_only = Rgb::new(0, 128, 0).luminance_u8();
890 let red_only = Rgb::new(128, 0, 0).luminance_u8();
891 let blue_only = Rgb::new(0, 0, 128).luminance_u8();
892 assert!(green_only > red_only);
893 assert!(green_only > blue_only);
894 }
895
896 #[test]
897 fn rgb_from_packed_rgba() {
898 let packed = PackedRgba::rgb(10, 20, 30);
899 let rgb: Rgb = packed.into();
900 assert_eq!(rgb, Rgb::new(10, 20, 30));
901 }
902
903 #[test]
904 fn relative_luminance_packed_ignores_alpha() {
905 let opaque = PackedRgba::rgba(10, 20, 30, 255);
906 let transparent = PackedRgba::rgba(10, 20, 30, 0);
907 let l1 = relative_luminance_packed(opaque);
908 let l2 = relative_luminance_packed(transparent);
909 assert!(
910 (l1 - l2).abs() < 1.0e-12,
911 "luminance should ignore alpha (l1={l1}, l2={l2})"
912 );
913 }
914
915 #[test]
918 fn ansi16_from_u8_valid_range() {
919 for i in 0..=15 {
920 assert!(Ansi16::from_u8(i).is_some());
921 }
922 }
923
924 #[test]
925 fn ansi16_from_u8_invalid() {
926 assert!(Ansi16::from_u8(16).is_none());
927 assert!(Ansi16::from_u8(255).is_none());
928 }
929
930 #[test]
931 fn ansi16_round_trip() {
932 for i in 0..=15 {
933 let color = Ansi16::from_u8(i).unwrap();
934 assert_eq!(color.as_u8(), i);
935 }
936 }
937
938 #[test]
941 fn rgb_to_256_grayscale_rules() {
942 assert_eq!(rgb_to_256(0, 0, 0), 16);
943 assert_eq!(rgb_to_256(8, 8, 8), 232);
944 assert_eq!(rgb_to_256(18, 18, 18), 233);
945 assert_eq!(rgb_to_256(249, 249, 249), 231);
946 }
947
948 #[test]
949 fn rgb_to_256_primary_red() {
950 assert_eq!(rgb_to_256(255, 0, 0), 196);
951 }
952
953 #[test]
954 fn rgb_to_256_primary_green() {
955 assert_eq!(rgb_to_256(0, 255, 0), 46);
956 }
957
958 #[test]
959 fn rgb_to_256_primary_blue() {
960 assert_eq!(rgb_to_256(0, 0, 255), 21);
961 }
962
963 #[test]
966 fn ansi256_to_rgb_round_trip() {
967 let rgb = ansi256_to_rgb(196);
968 assert_eq!(rgb, Rgb::new(255, 0, 0));
969 }
970
971 #[test]
972 fn ansi256_to_rgb_first_16_match_palette() {
973 for i in 0..16 {
974 let rgb = ansi256_to_rgb(i);
975 assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
976 }
977 }
978
979 #[test]
980 fn ansi256_to_rgb_grayscale_ramp() {
981 let darkest = ansi256_to_rgb(232);
983 assert_eq!(darkest, Rgb::new(8, 8, 8));
984 let lightest = ansi256_to_rgb(255);
985 assert_eq!(lightest, Rgb::new(238, 238, 238));
986 }
987
988 #[test]
989 fn ansi256_color_cube_corners() {
990 assert_eq!(ansi256_to_rgb(16), Rgb::new(0, 0, 0));
992 assert_eq!(ansi256_to_rgb(231), Rgb::new(255, 255, 255));
994 }
995
996 #[test]
999 fn rgb_to_ansi16_basics() {
1000 assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1001 assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1002 assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1003 assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1004 }
1005
1006 #[test]
1007 fn rgb_to_ansi16_white() {
1008 assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1009 }
1010
1011 #[test]
1014 fn mono_fallback() {
1015 assert_eq!(rgb_to_mono(0, 0, 0), MonoColor::Black);
1016 assert_eq!(rgb_to_mono(255, 255, 255), MonoColor::White);
1017 assert_eq!(rgb_to_mono(200, 200, 200), MonoColor::White);
1018 assert_eq!(rgb_to_mono(30, 30, 30), MonoColor::Black);
1019 }
1020
1021 #[test]
1022 fn mono_boundary() {
1023 assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1025 assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1026 }
1027
1028 #[test]
1031 fn downgrade_rgb_to_ansi256() {
1032 let color = Color::rgb(255, 0, 0);
1033 let downgraded = color.downgrade(ColorProfile::Ansi256);
1034 assert!(matches!(downgraded, Color::Ansi256(_)));
1035 }
1036
1037 #[test]
1038 fn downgrade_rgb_to_ansi16() {
1039 let color = Color::rgb(255, 0, 0);
1040 let downgraded = color.downgrade(ColorProfile::Ansi16);
1041 assert!(matches!(downgraded, Color::Ansi16(_)));
1042 }
1043
1044 #[test]
1045 fn downgrade_rgb_to_mono() {
1046 let color = Color::rgb(255, 255, 255);
1047 let downgraded = color.downgrade(ColorProfile::Mono);
1048 assert_eq!(downgraded, Color::Mono(MonoColor::White));
1049 }
1050
1051 #[test]
1052 fn downgrade_ansi256_to_ansi16() {
1053 let color = Color::Ansi256(196);
1054 let downgraded = color.downgrade(ColorProfile::Ansi16);
1055 assert!(matches!(downgraded, Color::Ansi16(_)));
1056 }
1057
1058 #[test]
1059 fn downgrade_ansi256_to_mono() {
1060 let color = Color::Ansi256(232); let downgraded = color.downgrade(ColorProfile::Mono);
1062 assert_eq!(downgraded, Color::Mono(MonoColor::Black));
1063 }
1064
1065 #[test]
1066 fn downgrade_ansi16_to_mono() {
1067 let color = Color::Ansi16(Ansi16::BrightWhite);
1068 let downgraded = color.downgrade(ColorProfile::Mono);
1069 assert_eq!(downgraded, Color::Mono(MonoColor::White));
1070 }
1071
1072 #[test]
1073 fn downgrade_mono_stays_mono() {
1074 let color = Color::Mono(MonoColor::Black);
1075 assert_eq!(color.downgrade(ColorProfile::Mono), color);
1076 }
1077
1078 #[test]
1079 fn downgrade_ansi16_stays_at_ansi256() {
1080 let color = Color::Ansi16(Ansi16::Red);
1081 assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1083 }
1084
1085 #[test]
1088 fn color_to_rgb_all_variants() {
1089 assert_eq!(Color::rgb(1, 2, 3).to_rgb(), Rgb::new(1, 2, 3));
1090 assert_eq!(Color::Ansi256(196).to_rgb(), Rgb::new(255, 0, 0));
1091 assert_eq!(Color::Ansi16(Ansi16::Black).to_rgb(), Rgb::new(0, 0, 0));
1092 assert_eq!(
1093 Color::Mono(MonoColor::White).to_rgb(),
1094 Rgb::new(255, 255, 255)
1095 );
1096 assert_eq!(Color::Mono(MonoColor::Black).to_rgb(), Rgb::new(0, 0, 0));
1097 }
1098
1099 #[test]
1102 fn color_from_packed_rgba() {
1103 let packed = PackedRgba::rgb(42, 84, 126);
1104 let color: Color = packed.into();
1105 assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1106 }
1107
1108 #[test]
1109 fn color_from_packed_rgba_ignores_alpha() {
1110 let packed = PackedRgba::rgba(42, 84, 126, 10);
1111 let color: Color = packed.into();
1112 assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1113 }
1114
1115 #[test]
1118 fn cache_tracks_hits() {
1119 let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1120 let rgb = Rgb::new(10, 20, 30);
1121 let _ = cache.downgrade_rgb(rgb);
1122 let _ = cache.downgrade_rgb(rgb);
1123 let stats = cache.stats();
1124 assert_eq!(stats.hits, 1);
1125 assert_eq!(stats.misses, 1);
1126 assert_eq!(stats.size, 1);
1127 }
1128
1129 #[test]
1130 fn cache_clears_on_overflow() {
1131 let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 2);
1132 let _ = cache.downgrade_rgb(Rgb::new(1, 0, 0));
1133 let _ = cache.downgrade_rgb(Rgb::new(2, 0, 0));
1134 assert_eq!(cache.stats().size, 2);
1135 let _ = cache.downgrade_rgb(Rgb::new(3, 0, 0));
1137 assert_eq!(cache.stats().size, 1);
1138 }
1139
1140 #[test]
1141 fn cache_downgrade_packed() {
1142 let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1143 let packed = PackedRgba::rgb(255, 0, 0);
1144 let result = cache.downgrade_packed(packed);
1145 assert!(matches!(result, Color::Ansi16(_)));
1146 }
1147
1148 #[test]
1149 fn cache_default_capacity() {
1150 let cache = ColorCache::new(ColorProfile::TrueColor);
1151 assert_eq!(cache.stats().capacity, 4096);
1152 }
1153
1154 #[test]
1155 fn cache_minimum_capacity_is_one() {
1156 let cache = ColorCache::with_capacity(ColorProfile::Ansi16, 0);
1157 assert_eq!(cache.stats().capacity, 1);
1158 }
1159}
1160
1161#[cfg(test)]
1162mod downgrade_edge_cases {
1163 use super::*;
1169
1170 #[test]
1175 fn sequential_downgrade_truecolor_to_mono() {
1176 let white = Color::rgb(255, 255, 255);
1178 let black = Color::rgb(0, 0, 0);
1179
1180 let w256 = white.downgrade(ColorProfile::Ansi256);
1182 assert!(matches!(w256, Color::Ansi256(231))); let w16 = w256.downgrade(ColorProfile::Ansi16);
1184 assert!(matches!(w16, Color::Ansi16(Ansi16::BrightWhite)));
1185 let wmono = w16.downgrade(ColorProfile::Mono);
1186 assert_eq!(wmono, Color::Mono(MonoColor::White));
1187
1188 let b256 = black.downgrade(ColorProfile::Ansi256);
1190 assert!(matches!(b256, Color::Ansi256(16))); let b16 = b256.downgrade(ColorProfile::Ansi16);
1192 assert!(matches!(b16, Color::Ansi16(Ansi16::Black)));
1193 let bmono = b16.downgrade(ColorProfile::Mono);
1194 assert_eq!(bmono, Color::Mono(MonoColor::Black));
1195 }
1196
1197 #[test]
1198 fn sequential_downgrade_preserves_intent() {
1199 let red = Color::rgb(255, 0, 0);
1201
1202 let r256 = red.downgrade(ColorProfile::Ansi256);
1203 let Color::Ansi256(idx) = r256 else {
1204 panic!("Expected Ansi256");
1205 };
1206 assert_eq!(idx, 196); let r16 = r256.downgrade(ColorProfile::Ansi16);
1209 let Color::Ansi16(ansi) = r16 else {
1210 panic!("Expected Ansi16");
1211 };
1212 assert_eq!(ansi, Ansi16::BrightRed);
1214 }
1215
1216 #[test]
1221 fn rgb_to_256_grayscale_boundaries() {
1222 assert_eq!(rgb_to_256(0, 0, 0), 16);
1225 assert_eq!(rgb_to_256(7, 7, 7), 16);
1226
1227 assert_eq!(rgb_to_256(8, 8, 8), 232);
1229
1230 assert_eq!(rgb_to_256(249, 249, 249), 231);
1232 assert_eq!(rgb_to_256(255, 255, 255), 231);
1233
1234 assert_eq!(rgb_to_256(248, 248, 248), 255);
1236 }
1237
1238 #[test]
1239 fn rgb_to_256_grayscale_ramp_coverage() {
1240 for i in 0..24 {
1243 let gray_val = 8 + i * 10;
1244 let idx = rgb_to_256(gray_val, gray_val, gray_val);
1245 assert!(
1246 (232..=255).contains(&idx),
1247 "Gray {} mapped to {} (expected 232-255)",
1248 gray_val,
1249 idx
1250 );
1251 }
1252 }
1253
1254 #[test]
1255 fn rgb_to_256_cube_corners() {
1256 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);
1266 }
1267
1268 #[test]
1269 fn rgb_to_256_non_gray_avoids_grayscale() {
1270 let idx = rgb_to_256(100, 100, 99);
1273 assert!(
1275 (16..=231).contains(&idx),
1276 "Non-gray {} should use cube",
1277 idx
1278 );
1279 }
1280
1281 #[test]
1286 fn cube_index_boundaries() {
1287 assert_eq!(super::cube_index(0), 0);
1290 assert_eq!(super::cube_index(47), 0);
1291 assert_eq!(super::cube_index(48), 1);
1292 assert_eq!(super::cube_index(114), 1);
1293 assert_eq!(super::cube_index(115), 2);
1294 assert_eq!(super::cube_index(155), 3);
1295 assert_eq!(super::cube_index(195), 4);
1296 assert_eq!(super::cube_index(235), 5);
1297 assert_eq!(super::cube_index(255), 5);
1298 }
1299
1300 #[test]
1305 fn ansi256_to_rgb_full_range() {
1306 for i in 0..=255 {
1308 let rgb = ansi256_to_rgb(i);
1309 let _ = (rgb.r, rgb.g, rgb.b);
1311 }
1312 }
1313
1314 #[test]
1315 fn ansi256_to_rgb_grayscale_range() {
1316 for i in 232..=255 {
1318 let rgb = ansi256_to_rgb(i);
1319 assert_eq!(rgb.r, rgb.g);
1320 assert_eq!(rgb.g, rgb.b);
1321 }
1322 }
1323
1324 #[test]
1325 fn ansi256_to_rgb_first_16_are_palette() {
1326 for i in 0..16 {
1328 let rgb = ansi256_to_rgb(i);
1329 assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
1330 }
1331 }
1332
1333 #[test]
1338 fn rgb_to_ansi16_pure_primaries() {
1339 assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1341 assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1342 assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1344 }
1345
1346 #[test]
1347 fn rgb_to_ansi16_grays() {
1348 assert_eq!(rgb_to_ansi16(127, 127, 127), Ansi16::BrightBlack);
1350 assert_eq!(rgb_to_ansi16(200, 200, 200), Ansi16::White);
1352 }
1353
1354 #[test]
1355 fn rgb_to_ansi16_extremes() {
1356 assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1358 assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1359 }
1360
1361 #[test]
1366 fn rgb_to_mono_luminance_boundary() {
1367 assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1370 assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1371
1372 assert_eq!(rgb_to_mono(0, 180, 0), MonoColor::White);
1375 assert_eq!(rgb_to_mono(0, 178, 0), MonoColor::Black);
1376 }
1377
1378 #[test]
1379 fn rgb_to_mono_color_saturation_irrelevant() {
1380 assert_eq!(rgb_to_mono(255, 0, 0), MonoColor::Black);
1383 assert_eq!(rgb_to_mono(0, 255, 0), MonoColor::White);
1385 assert_eq!(rgb_to_mono(0, 0, 255), MonoColor::Black);
1387 }
1388
1389 #[test]
1394 fn downgrade_at_same_level_is_identity() {
1395 let ansi16 = Color::Ansi16(Ansi16::Red);
1397 assert_eq!(ansi16.downgrade(ColorProfile::Ansi16), ansi16);
1398
1399 let ansi256 = Color::Ansi256(100);
1400 assert_eq!(ansi256.downgrade(ColorProfile::Ansi256), ansi256);
1401
1402 let mono = Color::Mono(MonoColor::Black);
1403 assert_eq!(mono.downgrade(ColorProfile::Mono), mono);
1404
1405 let rgb = Color::rgb(1, 2, 3);
1406 assert_eq!(rgb.downgrade(ColorProfile::TrueColor), rgb);
1407 }
1408
1409 #[test]
1410 fn downgrade_ansi16_passes_through_ansi256() {
1411 let color = Color::Ansi16(Ansi16::Cyan);
1414 assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1415 }
1416
1417 #[test]
1418 fn downgrade_mono_passes_through_all() {
1419 let black = Color::Mono(MonoColor::Black);
1421 let white = Color::Mono(MonoColor::White);
1422
1423 assert_eq!(black.downgrade(ColorProfile::TrueColor), black);
1424 assert_eq!(black.downgrade(ColorProfile::Ansi256), black);
1425 assert_eq!(black.downgrade(ColorProfile::Ansi16), black);
1426 assert_eq!(black.downgrade(ColorProfile::Mono), black);
1427
1428 assert_eq!(white.downgrade(ColorProfile::TrueColor), white);
1429 assert_eq!(white.downgrade(ColorProfile::Ansi256), white);
1430 assert_eq!(white.downgrade(ColorProfile::Ansi16), white);
1431 assert_eq!(white.downgrade(ColorProfile::Mono), white);
1432 }
1433
1434 #[test]
1439 fn luminance_formula_correctness() {
1440 let r_luma = Rgb::new(255, 0, 0).luminance_u8();
1443 let g_luma = Rgb::new(0, 255, 0).luminance_u8();
1444 let b_luma = Rgb::new(0, 0, 255).luminance_u8();
1445
1446 assert!(
1448 (50..=58).contains(&r_luma),
1449 "Red luma {} not near 54",
1450 r_luma
1451 );
1452 assert!(
1454 (178..=186).contains(&g_luma),
1455 "Green luma {} not near 182",
1456 g_luma
1457 );
1458 assert!(
1460 (15..=22).contains(&b_luma),
1461 "Blue luma {} not near 18",
1462 b_luma
1463 );
1464
1465 let all = Rgb::new(255, 255, 255).luminance_u8();
1467 assert_eq!(all, 255);
1468 }
1469
1470 #[test]
1471 fn luminance_mid_values() {
1472 let mid_gray = Rgb::new(128, 128, 128).luminance_u8();
1474 assert!(
1476 (126..=130).contains(&mid_gray),
1477 "Mid gray luma {} not near 128",
1478 mid_gray
1479 );
1480 }
1481}