Skip to main content

standout_render/
colorspace.rs

1//! Theme-relative colorspace for perceptually uniform palette generation.
2//!
3//! # Motivation
4//!
5//! Terminal 256-color palettes have 240 extended colors (indices 16–255) that are
6//! hardcoded with fixed RGB values, completely ignoring the user's base16 theme.
7//! This module implements [jake-stewart's proposal][gist] to generate those colors
8//! by **trilinear interpolation in CIE LAB space** using the 8 base ANSI colors
9//! as cube corners.
10//!
11//! [gist]: https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
12//!
13//! # Concept: Theme-Relative Color
14//!
15//! Instead of addressing colors as absolute RGB values, this module lets you specify
16//! a **position in a color cube** whose corners are the user's theme colors:
17//!
18//! | Cube corner | ANSI color |
19//! |-------------|------------|
20//! | `(0, 0, 0)` | background (defaults to black) |
21//! | `(1, 0, 0)` | red |
22//! | `(0, 1, 0)` | green |
23//! | `(1, 1, 0)` | yellow |
24//! | `(0, 0, 1)` | blue |
25//! | `(1, 0, 1)` | magenta |
26//! | `(0, 1, 1)` | cyan |
27//! | `(1, 1, 1)` | foreground (defaults to white) |
28//!
29//! A [`CubeCoord`] like `(0.6, 0.2, 0.0)` means "60% toward red, 20% toward
30//! green, 0% blue" — the theme determines what that actually looks like on screen.
31//!
32//! # Why CIE LAB?
33//!
34//! LAB is a **perceptually uniform** colorspace: equal numerical distances correspond
35//! to equal perceived color differences. Interpolating in LAB (rather than RGB) ensures:
36//!
37//! - **Consistent brightness**: blue shades at level 3 look as bright as green at level 3
38//! - **Smooth gradients**: no muddy midpoints or perceptual jumps
39//! - **Hue preservation**: interpolation follows natural color transitions
40//!
41//! # Trilinear Interpolation
42//!
43//! The 8 theme colors sit at the corners of a unit cube. For any point `(r, g, b)`
44//! inside the cube, the color is computed by three nested linear interpolations in LAB:
45//!
46//! 1. **R-axis**: Interpolate 4 edge pairs (bg→red, green→yellow, blue→magenta, cyan→fg)
47//! 2. **G-axis**: Interpolate between the R-axis results to sweep across 2 faces
48//! 3. **B-axis**: Interpolate between the G-axis results to fill the volume
49//!
50//! At every grid point, the resulting color is a smooth blend of all 8 corner colors,
51//! weighted by proximity.
52//!
53//! # Example
54//!
55//! ```rust
56//! use standout_render::colorspace::{CubeCoord, Rgb, ThemePalette};
57//!
58//! // Define a gruvbox-like palette (8 base colors)
59//! let palette = ThemePalette::new([
60//!     Rgb(40, 40, 40),     // black
61//!     Rgb(204, 36, 29),    // red
62//!     Rgb(152, 151, 26),   // green
63//!     Rgb(215, 153, 33),   // yellow
64//!     Rgb(69, 133, 136),   // blue
65//!     Rgb(177, 98, 134),   // magenta
66//!     Rgb(104, 157, 106),  // cyan
67//!     Rgb(168, 153, 132),  // white
68//! ]);
69//!
70//! // Resolve a theme-relative coordinate to an actual RGB color
71//! let coord = CubeCoord::from_percentages(60.0, 20.0, 0.0).unwrap();
72//! let color = palette.resolve(&coord);
73//!
74//! // Generate a full 240-color extended palette (216 cube + 24 grayscale)
75//! let extended = palette.generate_palette(6);
76//! assert_eq!(extended.len(), 240);
77//! ```
78
79// ─── RGB type ───────────────────────────────────────────────────────────────
80
81/// A simple RGB color triplet.
82///
83/// This is the module's own RGB type, decoupled from any terminal or styling crate.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Rgb(pub u8, pub u8, pub u8);
86
87// ─── CIE LAB internals ─────────────────────────────────────────────────────
88
89/// CIE LAB color (internal representation for perceptually uniform interpolation).
90#[derive(Debug, Clone, Copy)]
91struct Lab {
92    l: f64,
93    a: f64,
94    b: f64,
95}
96
97/// D65 reference white point for CIE XYZ → LAB conversion.
98const XN: f64 = 0.95047;
99const YN: f64 = 1.00000;
100const ZN: f64 = 1.08883;
101
102/// Convert an sRGB component (0–255) to linear light (0.0–1.0).
103fn 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
112/// Convert a linear light value (0.0–1.0) to sRGB (0–255), clamped.
113fn 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
123/// LAB forward transform helper.
124fn 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
132/// LAB inverse transform helper.
133fn 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
141/// Convert an [`Rgb`] value to CIE LAB via XYZ (D65 illuminant).
142fn 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    // sRGB → XYZ (D65) using the standard matrix
148    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
163/// Convert a CIE LAB value back to [`Rgb`] via XYZ (D65 illuminant).
164fn 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    // XYZ → linear RGB (D65)
174    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
181/// Linearly interpolate between two LAB colors.
182fn 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// ─── CubeCoord ──────────────────────────────────────────────────────────────
191
192/// A position in the theme-relative color cube.
193///
194/// Each axis ranges from `0.0` to `1.0`, representing a fractional position
195/// between theme anchor colors:
196///
197/// - **r**: red axis — interpolates bg→red, green→yellow, blue→magenta, cyan→fg
198/// - **g**: green axis — interpolates between the r-axis edge pairs
199/// - **b**: blue axis — interpolates between the g-axis face results
200///
201/// Designers think in percentages: `cube(60%, 20%, 0%)` maps to
202/// `CubeCoord { r: 0.6, g: 0.2, b: 0.0 }`.
203#[derive(Debug, Clone, Copy, PartialEq)]
204pub struct CubeCoord {
205    // SAFETY: `Eq` is manually implemented because `f64` doesn't derive `Eq`.
206    // This is safe because all values are validated to be finite (0.0..=1.0)
207    // during construction, so `PartialEq` is reflexive.
208    /// Red axis fraction (0.0–1.0).
209    pub r: f64,
210    /// Green axis fraction (0.0–1.0).
211    pub g: f64,
212    /// Blue axis fraction (0.0–1.0).
213    pub b: f64,
214}
215
216impl Eq for CubeCoord {}
217
218impl CubeCoord {
219    /// Creates a new cube coordinate with fractional values (0.0–1.0 per axis).
220    ///
221    /// Returns an error if any component is outside the valid range.
222    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    /// Creates a cube coordinate from percentage values (0.0–100.0 per axis).
233    ///
234    /// This is the natural syntax for style definitions: `cube(60%, 20%, 0%)`
235    /// maps to `from_percentages(60.0, 20.0, 0.0)`.
236    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    /// Quantizes this coordinate to the nearest grid point for a given number
241    /// of subdivisions per axis.
242    ///
243    /// For the standard 256-color palette, `levels = 6` (producing a 6×6×6 cube).
244    /// Returns the integer grid coordinates `(r, g, b)` each in `0..levels`.
245    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    /// Returns the 256-color palette index for this coordinate.
254    ///
255    /// Uses the standard formula: `16 + 36*r + 6*g + b` where `r`, `g`, `b`
256    /// are quantized to `0..levels`. The offset of 16 accounts for the base16
257    /// ANSI colors that occupy indices 0–15.
258    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// ─── ThemePalette ───────────────────────────────────────────────────────────
266
267/// A set of 8 anchor colors that define a theme-relative color space.
268///
269/// The 8 anchors map to the standard ANSI color positions:
270///
271/// | Index | Color   | Cube corner     |
272/// |-------|---------|-----------------|
273/// | 0     | black   | `(0, 0, 0)`     |
274/// | 1     | red     | `(1, 0, 0)`     |
275/// | 2     | green   | `(0, 1, 0)`     |
276/// | 3     | yellow  | `(1, 1, 0)`     |
277/// | 4     | blue    | `(0, 0, 1)`     |
278/// | 5     | magenta | `(1, 0, 1)`     |
279/// | 6     | cyan    | `(0, 1, 1)`     |
280/// | 7     | white   | `(1, 1, 1)`     |
281///
282/// Optional background/foreground overrides let the bg/fg differ from the
283/// theme's black/white (e.g., Solarized where bg is a dark teal, not black).
284#[derive(Debug, Clone)]
285pub struct ThemePalette {
286    anchors: [Rgb; 8],
287    bg: Rgb,
288    fg: Rgb,
289}
290
291impl ThemePalette {
292    /// Creates a palette using the standard xterm base16 colors.
293    ///
294    /// This is the default when no explicit theme palette is configured.
295    pub fn default_xterm() -> Self {
296        Self::new([
297            Rgb(0, 0, 0),       // black
298            Rgb(205, 0, 0),     // red
299            Rgb(0, 205, 0),     // green
300            Rgb(205, 205, 0),   // yellow
301            Rgb(0, 0, 238),     // blue
302            Rgb(205, 0, 205),   // magenta
303            Rgb(0, 205, 205),   // cyan
304            Rgb(229, 229, 229), // white
305        ])
306    }
307
308    /// Creates a new palette from the 8 base ANSI colors.
309    ///
310    /// The array order must be: black, red, green, yellow, blue, magenta, cyan, white.
311    /// Background defaults to `anchors[0]` (black) and foreground to `anchors[7]` (white).
312    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    /// Overrides the background color used for the `(0,0,0)` cube corner.
319    ///
320    /// Useful for themes where the terminal background differs from ANSI black
321    /// (e.g., Solarized Dark uses `#002b36`).
322    pub fn with_bg(mut self, bg: Rgb) -> Self {
323        self.bg = bg;
324        self
325    }
326
327    /// Overrides the foreground color used for the `(1,1,1)` cube corner.
328    ///
329    /// Useful for themes where the terminal foreground differs from ANSI white
330    /// (e.g., Solarized Dark uses `#fdf6e3`).
331    pub fn with_fg(mut self, fg: Rgb) -> Self {
332        self.fg = fg;
333        self
334    }
335
336    /// Resolves a [`CubeCoord`] to an actual RGB color via trilinear LAB interpolation.
337    ///
338    /// This is the core operation: given a position in the theme cube, compute
339    /// the perceptually interpolated color between the 8 anchor corners.
340    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        // R-axis: interpolate 4 edge pairs
346        let c0 = lerp_lab(coord.r, &bg_lab, &labs[1]); // bg → red
347        let c1 = lerp_lab(coord.r, &labs[2], &labs[3]); // green → yellow
348        let c2 = lerp_lab(coord.r, &labs[4], &labs[5]); // blue → magenta
349        let c3 = lerp_lab(coord.r, &labs[6], &fg_lab); // cyan → fg
350
351        // G-axis: interpolate between edge pairs
352        let c4 = lerp_lab(coord.g, &c0, &c1);
353        let c5 = lerp_lab(coord.g, &c2, &c3);
354
355        // B-axis: interpolate between faces
356        let c6 = lerp_lab(coord.b, &c4, &c5);
357
358        lab_to_rgb(c6)
359    }
360
361    /// Generates the extended color palette by subdividing the theme cube.
362    ///
363    /// Returns a `Vec<Rgb>` containing:
364    /// - `subdivisions³` colors from the cube (e.g., 216 for subdivisions=6)
365    /// - 24 grayscale colors interpolated from background to foreground
366    ///
367    /// The total is `subdivisions³ + 24` colors. For subdivisions=6, this gives
368    /// the 240 extended colors that fill indices 16–255 of a 256-color palette.
369    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        // Color cube
378        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        // Grayscale ramp (24 steps between bg and fg, exclusive of endpoints)
399        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// ─── Tests ──────────────────────────────────────────────────────────────────
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    // =====================================================================
416    // LAB round-trip tests
417    // =====================================================================
418
419    /// Assert that RGB → LAB → RGB round-trips within tolerance.
420    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    // =====================================================================
474    // Known LAB values
475    // =====================================================================
476
477    #[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    // =====================================================================
504    // lerp_lab tests
505    // =====================================================================
506
507    #[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    // =====================================================================
536    // CubeCoord validation tests
537    // =====================================================================
538
539    #[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    // =====================================================================
577    // CubeCoord quantization tests
578    // =====================================================================
579
580    #[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        // 0.5 * 5 = 2.5, rounds to 3 (standard rounding, but actually let's check)
607        // Actually: 0.5 * 5.0 = 2.5, round() = 3 in Rust (round half to even? no, round half away from zero)
608        // f64::round(2.5) = 3.0 in Rust
609        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        // 0.2 * 5 = 1.0, rounds to 1
616        let (r, _, _) = CubeCoord::new(0.2, 0.0, 0.0).unwrap().quantize(6);
617        assert_eq!(r, 1);
618    }
619
620    // =====================================================================
621    // to_palette_index tests
622    // =====================================================================
623
624    #[test]
625    fn palette_index_origin() {
626        // (0,0,0) → 16 + 0 = 16
627        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        // (5,5,5) → 16 + 36*5 + 6*5 + 5 = 16 + 180 + 30 + 5 = 231
636        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        // (5,0,0) → 16 + 36*5 = 16 + 180 = 196
645        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        // (0,0,5) → 16 + 5 = 21
654        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        // (0,5,0) → 16 + 30 = 46
663        assert_eq!(
664            CubeCoord::new(0.0, 1.0, 0.0).unwrap().to_palette_index(6),
665            46
666        );
667    }
668
669    // =====================================================================
670    // ThemePalette resolve tests
671    // =====================================================================
672
673    /// Standard xterm-like base colors for testing.
674    fn test_palette() -> ThemePalette {
675        ThemePalette::new([
676            Rgb(0, 0, 0),       // black
677            Rgb(205, 0, 0),     // red
678            Rgb(0, 205, 0),     // green
679            Rgb(205, 205, 0),   // yellow
680            Rgb(0, 0, 238),     // blue
681            Rgb(205, 0, 205),   // magenta
682            Rgb(0, 205, 205),   // cyan
683            Rgb(229, 229, 229), // white
684        ])
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        // Center should not be any corner color
757        assert_ne!(rgb, Rgb(0, 0, 0));
758        assert_ne!(rgb, Rgb(255, 255, 255));
759        // Should be somewhere in the middle range
760        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    // =====================================================================
779    // generate_palette tests
780    // =====================================================================
781
782    #[test]
783    fn generate_palette_correct_count() {
784        let palette = test_palette();
785        let extended = palette.generate_palette(6);
786        // 6^3 = 216 cube colors + 24 grayscale = 240
787        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        // Last cube entry is index 215 (6^3 - 1), which is (5,5,5) = fg
802        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        // Red corner is (5,0,0) → index 5*36 = 180
810        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        // Grayscale ramp is the last 24 entries
818        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        // 4^3 = 64 + 24 = 88
837        let small = palette.generate_palette(4);
838        assert_eq!(small.len(), 88);
839        // 8^3 = 512 + 24 = 536
840        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),    // black
848            Rgb(204, 36, 29),   // red
849            Rgb(152, 151, 26),  // green
850            Rgb(215, 153, 33),  // yellow
851            Rgb(69, 133, 136),  // blue
852            Rgb(177, 98, 134),  // magenta
853            Rgb(104, 157, 106), // cyan
854            Rgb(168, 153, 132), // white
855        ])
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        // bg corner should match
863        assert_eq!(extended[0], Rgb(40, 40, 40));
864        // fg corner should match
865        assert_eq!(extended[215], Rgb(235, 219, 178));
866    }
867}