1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Rgb(pub u8, pub u8, pub u8);
86
87#[derive(Debug, Clone, Copy)]
91struct Lab {
92 l: f64,
93 a: f64,
94 b: f64,
95}
96
97const XN: f64 = 0.95047;
99const YN: f64 = 1.00000;
100const ZN: f64 = 1.08883;
101
102fn srgb_to_linear(c: u8) -> f64 {
104 let c = c as f64 / 255.0;
105 if c <= 0.04045 {
106 c / 12.92
107 } else {
108 ((c + 0.055) / 1.055).powf(2.4)
109 }
110}
111
112fn linear_to_srgb(c: f64) -> u8 {
114 let c = c.clamp(0.0, 1.0);
115 let s = if c <= 0.0031308 {
116 12.92 * c
117 } else {
118 1.055 * c.powf(1.0 / 2.4) - 0.055
119 };
120 (s * 255.0).round() as u8
121}
122
123fn lab_f(t: f64) -> f64 {
125 if t > 0.008856 {
126 t.cbrt()
127 } else {
128 7.787 * t + 16.0 / 116.0
129 }
130}
131
132fn lab_f_inv(t: f64) -> f64 {
134 if t > 0.206896 {
135 t * t * t
136 } else {
137 (t - 16.0 / 116.0) / 7.787
138 }
139}
140
141fn rgb_to_lab(rgb: Rgb) -> Lab {
143 let r = srgb_to_linear(rgb.0);
144 let g = srgb_to_linear(rgb.1);
145 let b = srgb_to_linear(rgb.2);
146
147 let x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
149 let y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
150 let z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b;
151
152 let fx = lab_f(x / XN);
153 let fy = lab_f(y / YN);
154 let fz = lab_f(z / ZN);
155
156 Lab {
157 l: 116.0 * fy - 16.0,
158 a: 500.0 * (fx - fy),
159 b: 200.0 * (fy - fz),
160 }
161}
162
163fn lab_to_rgb(lab: Lab) -> Rgb {
165 let fy = (lab.l + 16.0) / 116.0;
166 let fx = lab.a / 500.0 + fy;
167 let fz = fy - lab.b / 200.0;
168
169 let x = XN * lab_f_inv(fx);
170 let y = YN * lab_f_inv(fy);
171 let z = ZN * lab_f_inv(fz);
172
173 let r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z;
175 let g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z;
176 let b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z;
177
178 Rgb(linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(b))
179}
180
181fn lerp_lab(t: f64, a: &Lab, b: &Lab) -> Lab {
183 Lab {
184 l: a.l + t * (b.l - a.l),
185 a: a.a + t * (b.a - a.a),
186 b: a.b + t * (b.b - a.b),
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq)]
204pub struct CubeCoord {
205 pub r: f64,
210 pub g: f64,
212 pub b: f64,
214}
215
216impl Eq for CubeCoord {}
217
218impl CubeCoord {
219 pub fn new(r: f64, g: f64, b: f64) -> Result<Self, String> {
223 if !(0.0..=1.0).contains(&r) || !(0.0..=1.0).contains(&g) || !(0.0..=1.0).contains(&b) {
224 return Err(format!(
225 "CubeCoord components must be 0.0..=1.0, got ({}, {}, {})",
226 r, g, b
227 ));
228 }
229 Ok(Self { r, g, b })
230 }
231
232 pub fn from_percentages(r: f64, g: f64, b: f64) -> Result<Self, String> {
237 Self::new(r / 100.0, g / 100.0, b / 100.0)
238 }
239
240 pub fn quantize(&self, levels: u8) -> (u8, u8, u8) {
246 let max = (levels - 1) as f64;
247 let r = (self.r * max).round() as u8;
248 let g = (self.g * max).round() as u8;
249 let b = (self.b * max).round() as u8;
250 (r.min(levels - 1), g.min(levels - 1), b.min(levels - 1))
251 }
252
253 pub fn to_palette_index(&self, levels: u8) -> u8 {
259 let (r, g, b) = self.quantize(levels);
260 let levels_sq = levels as u16 * levels as u16;
261 (16 + levels_sq * r as u16 + levels as u16 * g as u16 + b as u16) as u8
262 }
263}
264
265#[derive(Debug, Clone)]
285pub struct ThemePalette {
286 anchors: [Rgb; 8],
287 bg: Rgb,
288 fg: Rgb,
289}
290
291impl ThemePalette {
292 pub fn default_xterm() -> Self {
296 Self::new([
297 Rgb(0, 0, 0), Rgb(205, 0, 0), Rgb(0, 205, 0), Rgb(205, 205, 0), Rgb(0, 0, 238), Rgb(205, 0, 205), Rgb(0, 205, 205), Rgb(229, 229, 229), ])
306 }
307
308 pub fn new(anchors: [Rgb; 8]) -> Self {
313 let bg = anchors[0];
314 let fg = anchors[7];
315 Self { anchors, bg, fg }
316 }
317
318 pub fn with_bg(mut self, bg: Rgb) -> Self {
323 self.bg = bg;
324 self
325 }
326
327 pub fn with_fg(mut self, fg: Rgb) -> Self {
332 self.fg = fg;
333 self
334 }
335
336 pub fn resolve(&self, coord: &CubeCoord) -> Rgb {
341 let bg_lab = rgb_to_lab(self.bg);
342 let fg_lab = rgb_to_lab(self.fg);
343 let labs: Vec<Lab> = self.anchors.iter().map(|c| rgb_to_lab(*c)).collect();
344
345 let c0 = lerp_lab(coord.r, &bg_lab, &labs[1]); let c1 = lerp_lab(coord.r, &labs[2], &labs[3]); let c2 = lerp_lab(coord.r, &labs[4], &labs[5]); let c3 = lerp_lab(coord.r, &labs[6], &fg_lab); let c4 = lerp_lab(coord.g, &c0, &c1);
353 let c5 = lerp_lab(coord.g, &c2, &c3);
354
355 let c6 = lerp_lab(coord.b, &c4, &c5);
357
358 lab_to_rgb(c6)
359 }
360
361 pub fn generate_palette(&self, subdivisions: u8) -> Vec<Rgb> {
370 let bg_lab = rgb_to_lab(self.bg);
371 let fg_lab = rgb_to_lab(self.fg);
372 let labs: Vec<Lab> = self.anchors.iter().map(|c| rgb_to_lab(*c)).collect();
373 let max = (subdivisions - 1) as f64;
374
375 let mut palette = Vec::new();
376
377 for r in 0..subdivisions {
379 let rt = r as f64 / max;
380 let c0 = lerp_lab(rt, &bg_lab, &labs[1]);
381 let c1 = lerp_lab(rt, &labs[2], &labs[3]);
382 let c2 = lerp_lab(rt, &labs[4], &labs[5]);
383 let c3 = lerp_lab(rt, &labs[6], &fg_lab);
384
385 for g in 0..subdivisions {
386 let gt = g as f64 / max;
387 let c4 = lerp_lab(gt, &c0, &c1);
388 let c5 = lerp_lab(gt, &c2, &c3);
389
390 for b in 0..subdivisions {
391 let bt = b as f64 / max;
392 let c6 = lerp_lab(bt, &c4, &c5);
393 palette.push(lab_to_rgb(c6));
394 }
395 }
396 }
397
398 for i in 0..24 {
400 let t = (i + 1) as f64 / 25.0;
401 let lab = lerp_lab(t, &bg_lab, &fg_lab);
402 palette.push(lab_to_rgb(lab));
403 }
404
405 palette
406 }
407}
408
409#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn assert_rgb_roundtrip(rgb: Rgb, tolerance: u8) {
421 let lab = rgb_to_lab(rgb);
422 let back = lab_to_rgb(lab);
423 let dr = (rgb.0 as i16 - back.0 as i16).unsigned_abs() as u8;
424 let dg = (rgb.1 as i16 - back.1 as i16).unsigned_abs() as u8;
425 let db = (rgb.2 as i16 - back.2 as i16).unsigned_abs() as u8;
426 assert!(
427 dr <= tolerance && dg <= tolerance && db <= tolerance,
428 "Round-trip failed: {:?} → {:?} → {:?} (delta: {}, {}, {})",
429 rgb,
430 lab,
431 back,
432 dr,
433 dg,
434 db
435 );
436 }
437
438 #[test]
439 fn roundtrip_black() {
440 assert_rgb_roundtrip(Rgb(0, 0, 0), 1);
441 }
442
443 #[test]
444 fn roundtrip_white() {
445 assert_rgb_roundtrip(Rgb(255, 255, 255), 1);
446 }
447
448 #[test]
449 fn roundtrip_pure_red() {
450 assert_rgb_roundtrip(Rgb(255, 0, 0), 1);
451 }
452
453 #[test]
454 fn roundtrip_pure_green() {
455 assert_rgb_roundtrip(Rgb(0, 255, 0), 1);
456 }
457
458 #[test]
459 fn roundtrip_pure_blue() {
460 assert_rgb_roundtrip(Rgb(0, 0, 255), 1);
461 }
462
463 #[test]
464 fn roundtrip_mid_gray() {
465 assert_rgb_roundtrip(Rgb(128, 128, 128), 1);
466 }
467
468 #[test]
469 fn roundtrip_arbitrary_color() {
470 assert_rgb_roundtrip(Rgb(200, 100, 50), 1);
471 }
472
473 #[test]
478 fn lab_black_is_zero_lightness() {
479 let lab = rgb_to_lab(Rgb(0, 0, 0));
480 assert!(lab.l.abs() < 1.0, "Black L* should be ~0, got {}", lab.l);
481 }
482
483 #[test]
484 fn lab_white_is_full_lightness() {
485 let lab = rgb_to_lab(Rgb(255, 255, 255));
486 assert!(
487 (lab.l - 100.0).abs() < 1.0,
488 "White L* should be ~100, got {}",
489 lab.l
490 );
491 }
492
493 #[test]
494 fn lab_red_has_positive_a() {
495 let lab = rgb_to_lab(Rgb(255, 0, 0));
496 assert!(
497 lab.a > 50.0,
498 "Red should have large positive a*, got {}",
499 lab.a
500 );
501 }
502
503 #[test]
508 fn lerp_at_zero_returns_first() {
509 let a = rgb_to_lab(Rgb(255, 0, 0));
510 let b = rgb_to_lab(Rgb(0, 0, 255));
511 let result = lerp_lab(0.0, &a, &b);
512 assert!((result.l - a.l).abs() < 0.001);
513 assert!((result.a - a.a).abs() < 0.001);
514 assert!((result.b - a.b).abs() < 0.001);
515 }
516
517 #[test]
518 fn lerp_at_one_returns_second() {
519 let a = rgb_to_lab(Rgb(255, 0, 0));
520 let b = rgb_to_lab(Rgb(0, 0, 255));
521 let result = lerp_lab(1.0, &a, &b);
522 assert!((result.l - b.l).abs() < 0.001);
523 assert!((result.a - b.a).abs() < 0.001);
524 assert!((result.b - b.b).abs() < 0.001);
525 }
526
527 #[test]
528 fn lerp_midpoint_is_between() {
529 let a = rgb_to_lab(Rgb(0, 0, 0));
530 let b = rgb_to_lab(Rgb(255, 255, 255));
531 let mid = lerp_lab(0.5, &a, &b);
532 assert!(mid.l > a.l && mid.l < b.l);
533 }
534
535 #[test]
540 fn cubecoord_valid_range() {
541 assert!(CubeCoord::new(0.0, 0.0, 0.0).is_ok());
542 assert!(CubeCoord::new(1.0, 1.0, 1.0).is_ok());
543 assert!(CubeCoord::new(0.5, 0.5, 0.5).is_ok());
544 }
545
546 #[test]
547 fn cubecoord_rejects_negative() {
548 assert!(CubeCoord::new(-0.1, 0.0, 0.0).is_err());
549 assert!(CubeCoord::new(0.0, -0.1, 0.0).is_err());
550 assert!(CubeCoord::new(0.0, 0.0, -0.1).is_err());
551 }
552
553 #[test]
554 fn cubecoord_rejects_over_one() {
555 assert!(CubeCoord::new(1.1, 0.0, 0.0).is_err());
556 assert!(CubeCoord::new(0.0, 1.1, 0.0).is_err());
557 assert!(CubeCoord::new(0.0, 0.0, 1.1).is_err());
558 }
559
560 #[test]
561 fn cubecoord_from_percentages() {
562 let coord = CubeCoord::from_percentages(60.0, 20.0, 0.0).unwrap();
563 assert!((coord.r - 0.6).abs() < 0.001);
564 assert!((coord.g - 0.2).abs() < 0.001);
565 assert!((coord.b - 0.0).abs() < 0.001);
566 }
567
568 #[test]
569 fn cubecoord_from_percentages_bounds() {
570 assert!(CubeCoord::from_percentages(0.0, 0.0, 0.0).is_ok());
571 assert!(CubeCoord::from_percentages(100.0, 100.0, 100.0).is_ok());
572 assert!(CubeCoord::from_percentages(101.0, 0.0, 0.0).is_err());
573 assert!(CubeCoord::from_percentages(-1.0, 0.0, 0.0).is_err());
574 }
575
576 #[test]
581 fn quantize_corners_levels_6() {
582 assert_eq!(
583 CubeCoord::new(0.0, 0.0, 0.0).unwrap().quantize(6),
584 (0, 0, 0)
585 );
586 assert_eq!(
587 CubeCoord::new(1.0, 1.0, 1.0).unwrap().quantize(6),
588 (5, 5, 5)
589 );
590 assert_eq!(
591 CubeCoord::new(1.0, 0.0, 0.0).unwrap().quantize(6),
592 (5, 0, 0)
593 );
594 assert_eq!(
595 CubeCoord::new(0.0, 1.0, 0.0).unwrap().quantize(6),
596 (0, 5, 0)
597 );
598 assert_eq!(
599 CubeCoord::new(0.0, 0.0, 1.0).unwrap().quantize(6),
600 (0, 0, 5)
601 );
602 }
603
604 #[test]
605 fn quantize_midpoint_levels_6() {
606 let (r, g, b) = CubeCoord::new(0.5, 0.5, 0.5).unwrap().quantize(6);
610 assert_eq!((r, g, b), (3, 3, 3));
611 }
612
613 #[test]
614 fn quantize_one_fifth_levels_6() {
615 let (r, _, _) = CubeCoord::new(0.2, 0.0, 0.0).unwrap().quantize(6);
617 assert_eq!(r, 1);
618 }
619
620 #[test]
625 fn palette_index_origin() {
626 assert_eq!(
628 CubeCoord::new(0.0, 0.0, 0.0).unwrap().to_palette_index(6),
629 16
630 );
631 }
632
633 #[test]
634 fn palette_index_max() {
635 assert_eq!(
637 CubeCoord::new(1.0, 1.0, 1.0).unwrap().to_palette_index(6),
638 231
639 );
640 }
641
642 #[test]
643 fn palette_index_pure_red() {
644 assert_eq!(
646 CubeCoord::new(1.0, 0.0, 0.0).unwrap().to_palette_index(6),
647 196
648 );
649 }
650
651 #[test]
652 fn palette_index_pure_blue() {
653 assert_eq!(
655 CubeCoord::new(0.0, 0.0, 1.0).unwrap().to_palette_index(6),
656 21
657 );
658 }
659
660 #[test]
661 fn palette_index_pure_green() {
662 assert_eq!(
664 CubeCoord::new(0.0, 1.0, 0.0).unwrap().to_palette_index(6),
665 46
666 );
667 }
668
669 fn test_palette() -> ThemePalette {
675 ThemePalette::new([
676 Rgb(0, 0, 0), Rgb(205, 0, 0), Rgb(0, 205, 0), Rgb(205, 205, 0), Rgb(0, 0, 238), Rgb(205, 0, 205), Rgb(0, 205, 205), Rgb(229, 229, 229), ])
685 }
686
687 #[test]
688 fn resolve_corner_bg() {
689 let palette = test_palette();
690 let coord = CubeCoord::new(0.0, 0.0, 0.0).unwrap();
691 let rgb = palette.resolve(&coord);
692 assert_eq!(rgb, Rgb(0, 0, 0));
693 }
694
695 #[test]
696 fn resolve_corner_red() {
697 let palette = test_palette();
698 let coord = CubeCoord::new(1.0, 0.0, 0.0).unwrap();
699 let rgb = palette.resolve(&coord);
700 assert_eq!(rgb, Rgb(205, 0, 0));
701 }
702
703 #[test]
704 fn resolve_corner_green() {
705 let palette = test_palette();
706 let coord = CubeCoord::new(0.0, 1.0, 0.0).unwrap();
707 let rgb = palette.resolve(&coord);
708 assert_eq!(rgb, Rgb(0, 205, 0));
709 }
710
711 #[test]
712 fn resolve_corner_yellow() {
713 let palette = test_palette();
714 let coord = CubeCoord::new(1.0, 1.0, 0.0).unwrap();
715 let rgb = palette.resolve(&coord);
716 assert_eq!(rgb, Rgb(205, 205, 0));
717 }
718
719 #[test]
720 fn resolve_corner_blue() {
721 let palette = test_palette();
722 let coord = CubeCoord::new(0.0, 0.0, 1.0).unwrap();
723 let rgb = palette.resolve(&coord);
724 assert_eq!(rgb, Rgb(0, 0, 238));
725 }
726
727 #[test]
728 fn resolve_corner_magenta() {
729 let palette = test_palette();
730 let coord = CubeCoord::new(1.0, 0.0, 1.0).unwrap();
731 let rgb = palette.resolve(&coord);
732 assert_eq!(rgb, Rgb(205, 0, 205));
733 }
734
735 #[test]
736 fn resolve_corner_cyan() {
737 let palette = test_palette();
738 let coord = CubeCoord::new(0.0, 1.0, 1.0).unwrap();
739 let rgb = palette.resolve(&coord);
740 assert_eq!(rgb, Rgb(0, 205, 205));
741 }
742
743 #[test]
744 fn resolve_corner_fg() {
745 let palette = test_palette();
746 let coord = CubeCoord::new(1.0, 1.0, 1.0).unwrap();
747 let rgb = palette.resolve(&coord);
748 assert_eq!(rgb, Rgb(229, 229, 229));
749 }
750
751 #[test]
752 fn resolve_center_is_blend() {
753 let palette = test_palette();
754 let coord = CubeCoord::new(0.5, 0.5, 0.5).unwrap();
755 let rgb = palette.resolve(&coord);
756 assert_ne!(rgb, Rgb(0, 0, 0));
758 assert_ne!(rgb, Rgb(255, 255, 255));
759 assert!(rgb.0 > 50 && rgb.0 < 200);
761 assert!(rgb.1 > 50 && rgb.1 < 200);
762 assert!(rgb.2 > 50 && rgb.2 < 200);
763 }
764
765 #[test]
766 fn resolve_with_custom_bg_fg() {
767 let palette = test_palette()
768 .with_bg(Rgb(30, 30, 46))
769 .with_fg(Rgb(205, 214, 244));
770
771 let origin = palette.resolve(&CubeCoord::new(0.0, 0.0, 0.0).unwrap());
772 assert_eq!(origin, Rgb(30, 30, 46));
773
774 let corner = palette.resolve(&CubeCoord::new(1.0, 1.0, 1.0).unwrap());
775 assert_eq!(corner, Rgb(205, 214, 244));
776 }
777
778 #[test]
783 fn generate_palette_correct_count() {
784 let palette = test_palette();
785 let extended = palette.generate_palette(6);
786 assert_eq!(extended.len(), 240);
788 }
789
790 #[test]
791 fn generate_palette_first_entry_is_bg() {
792 let palette = test_palette();
793 let extended = palette.generate_palette(6);
794 assert_eq!(extended[0], Rgb(0, 0, 0));
795 }
796
797 #[test]
798 fn generate_palette_last_cube_entry_is_fg() {
799 let palette = test_palette();
800 let extended = palette.generate_palette(6);
801 assert_eq!(extended[215], Rgb(229, 229, 229));
803 }
804
805 #[test]
806 fn generate_palette_red_corner() {
807 let palette = test_palette();
808 let extended = palette.generate_palette(6);
809 assert_eq!(extended[180], Rgb(205, 0, 0));
811 }
812
813 #[test]
814 fn generate_palette_grayscale_monotonic_lightness() {
815 let palette = test_palette();
816 let extended = palette.generate_palette(6);
817 let grayscale = &extended[216..240];
819
820 for i in 1..grayscale.len() {
821 let prev_l = rgb_to_lab(grayscale[i - 1]).l;
822 let curr_l = rgb_to_lab(grayscale[i]).l;
823 assert!(
824 curr_l >= prev_l - 0.01,
825 "Grayscale lightness not monotonic at index {}: {} < {}",
826 i,
827 curr_l,
828 prev_l
829 );
830 }
831 }
832
833 #[test]
834 fn generate_palette_different_subdivisions() {
835 let palette = test_palette();
836 let small = palette.generate_palette(4);
838 assert_eq!(small.len(), 88);
839 let large = palette.generate_palette(8);
841 assert_eq!(large.len(), 536);
842 }
843
844 #[test]
845 fn generate_palette_with_gruvbox() {
846 let palette = ThemePalette::new([
847 Rgb(40, 40, 40), Rgb(204, 36, 29), Rgb(152, 151, 26), Rgb(215, 153, 33), Rgb(69, 133, 136), Rgb(177, 98, 134), Rgb(104, 157, 106), Rgb(168, 153, 132), ])
856 .with_bg(Rgb(40, 40, 40))
857 .with_fg(Rgb(235, 219, 178));
858
859 let extended = palette.generate_palette(6);
860 assert_eq!(extended.len(), 240);
861
862 assert_eq!(extended[0], Rgb(40, 40, 40));
864 assert_eq!(extended[215], Rgb(235, 219, 178));
866 }
867}