Skip to main content

rio_theme/
color.rs

1//! The single value type the theme engine passes around.
2//!
3//! Stored as OKLCH (perceptually uniform). sRGB hex strings are an
4//! I/O concern only — `from_hex` is the inbound door, `to_hex` the
5//! outbound. Engine math (`mix`, `lighten`, `darken`, hue distance,
6//! chroma checks) all happens in OKLCH or OKLab and never round-trips
7//! through gamma-encoded sRGB.
8//!
9//! The OKLab matrices are Björn Ottosson's published constants
10//! (https://bottosson.github.io/posts/oklab/). They are embedded
11//! literally rather than computed at runtime so this file stays
12//! self-contained and `cargo expand`-friendly.
13
14use std::fmt;
15
16/// A color stored in OKLCH. All engine math happens in this space.
17///
18/// - `l` (lightness) in `0.0..=1.0`. 0 is black, 1 is white.
19/// - `c` (chroma) in `0.0..~0.4`. 0 is achromatic; ~0.37 is a vivid
20///   sRGB-gamut red. Values above the gamut clip on emission.
21/// - `h` (hue) in degrees `0.0..360.0`. Undefined when chroma is 0,
22///   but we always store a value (typically 0) for `Copy`/`PartialEq`.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct Color {
25    /// Lightness in `0.0..=1.0`. 0 is black, 1 is white. Perceptually
26    /// uniform — equal steps in `l` look equally bright to the eye.
27    pub l: f64,
28    /// Chroma in `0.0..~0.4`. 0 is achromatic; ~0.37 is a vivid
29    /// sRGB-gamut red. Values above the gamut clip on emission.
30    pub c: f64,
31    /// Hue in degrees `0.0..360.0`. Undefined when `c` is 0 but
32    /// always stored (typically 0) so the struct remains `Copy` and
33    /// `PartialEq`.
34    pub h: f64,
35}
36
37/// Failure mode of [`Color::from_hex`]. Returned instead of panicking
38/// because the engine accepts client-supplied strings.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ColorError {
41    /// Missing the leading `#`.
42    MissingHash,
43    /// Length was not 4 (`#rgb`) or 7 (`#rrggbb`).
44    BadLength,
45    /// Body contained a non-hexadecimal character.
46    BadDigit,
47}
48
49impl fmt::Display for ColorError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            ColorError::MissingHash => f.write_str("hex color must start with '#'"),
53            ColorError::BadLength => {
54                f.write_str("hex color must be '#rgb' or '#rrggbb' (4 or 7 chars)")
55            }
56            ColorError::BadDigit => f.write_str("hex color contains a non-hex character"),
57        }
58    }
59}
60
61impl std::error::Error for ColorError {}
62
63impl Color {
64    /// Construct directly from OKLCH components. Used by tests and by
65    /// pipeline stages that compute coordinates rather than parse strings.
66    pub fn from_oklch(l: f64, c: f64, h: f64) -> Self {
67        Color {
68            l,
69            c,
70            h: ((h % 360.0) + 360.0) % 360.0,
71        }
72    }
73
74    /// Parse `#rrggbb` or `#rgb`. Never panics on bad input.
75    pub fn from_hex(hex: &str) -> Result<Self, ColorError> {
76        let bytes = hex.as_bytes();
77        if bytes.is_empty() || bytes[0] != b'#' {
78            return Err(ColorError::MissingHash);
79        }
80        let body = &hex[1..];
81        let (r, g, b) = match body.len() {
82            3 => {
83                let r = expand_nibble(byte(body, 0)?);
84                let g = expand_nibble(byte(body, 1)?);
85                let b = expand_nibble(byte(body, 2)?);
86                (r, g, b)
87            }
88            6 => {
89                let r = pair(body, 0)?;
90                let g = pair(body, 2)?;
91                let b = pair(body, 4)?;
92                (r, g, b)
93            }
94            _ => return Err(ColorError::BadLength),
95        };
96        let srgb = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
97        Ok(srgb_to_oklch(srgb))
98    }
99
100    /// Emit a `#rrggbb` string. Converts to sRGB and clamps each
101    /// channel into `0..=1` before quantizing — out-of-gamut OKLCH
102    /// coordinates are projected by channel clamping, not by hue shift.
103    pub fn to_hex(&self) -> String {
104        let [r, g, b] = oklch_to_srgb(*self);
105        let r = quantize(r);
106        let g = quantize(g);
107        let b = quantize(b);
108        format!("#{r:02x}{g:02x}{b:02x}")
109    }
110
111    /// Mix toward `other` by `amount` (0.0 = self, 1.0 = other).
112    ///
113    /// Interpolation happens in OKLab — L, a, b each interpolate
114    /// linearly — so the result tracks perceived color without the
115    /// hue-wraparound traps of LCh-space mixing. This is the engine's
116    /// port of CSS `color-mix()` for the cases this engine needs.
117    pub fn mix(&self, other: &Color, amount: f64) -> Color {
118        let a = amount.clamp(0.0, 1.0);
119        let (la, aa, ba) = oklch_to_oklab(self.l, self.c, self.h);
120        let (lb, ab, bb) = oklch_to_oklab(other.l, other.c, other.h);
121        let l = la + (lb - la) * a;
122        let ax = aa + (ab - aa) * a;
123        let bx = ba + (bb - ba) * a;
124        let (l, c, h) = oklab_to_oklch(l, ax, bx);
125        Color { l, c, h }
126    }
127
128    /// Mix toward pure white by `amount`. Convenience wrapper used
129    /// across the derivation rules (§5).
130    pub fn lighten(&self, amount: f64) -> Color {
131        let white = Color {
132            l: 1.0,
133            c: 0.0,
134            h: 0.0,
135        };
136        self.mix(&white, amount)
137    }
138
139    /// Mix toward near-black (#111) by `amount`. Convenience wrapper
140    /// for hover / active / dark-text derivations (§5). Near-black
141    /// rather than pure black so the result keeps a hair of warmth
142    /// instead of going dead-flat.
143    pub fn darken(&self, amount: f64) -> Color {
144        // #111 in OKLCH coordinates (L≈0.18, C=0).
145        let near_black = Color::from_hex("#111111").expect("constant");
146        self.mix(&near_black, amount)
147    }
148}
149
150// --- hex helpers ---------------------------------------------------
151
152fn byte(s: &str, i: usize) -> Result<u8, ColorError> {
153    let c = s.as_bytes()[i] as char;
154    c.to_digit(16).map(|d| d as u8).ok_or(ColorError::BadDigit)
155}
156
157fn pair(s: &str, i: usize) -> Result<u8, ColorError> {
158    let hi = byte(s, i)?;
159    let lo = byte(s, i + 1)?;
160    Ok((hi << 4) | lo)
161}
162
163fn expand_nibble(n: u8) -> u8 {
164    (n << 4) | n
165}
166
167fn quantize(v: f64) -> u8 {
168    (v.clamp(0.0, 1.0) * 255.0).round() as u8
169}
170
171// --- sRGB <-> linear sRGB -----------------------------------------
172
173fn srgb_decode(v: f64) -> f64 {
174    if v <= 0.04045 {
175        v / 12.92
176    } else {
177        ((v + 0.055) / 1.055).powf(2.4)
178    }
179}
180
181fn srgb_encode(v: f64) -> f64 {
182    if v <= 0.0031308 {
183        v * 12.92
184    } else {
185        1.055 * v.powf(1.0 / 2.4) - 0.055
186    }
187}
188
189// --- linear sRGB <-> OKLab ----------------------------------------
190//
191// Ottosson's matrices; do not edit without re-deriving from the
192// original paper.
193
194fn linear_srgb_to_oklab(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
195    let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
196    let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
197    let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
198
199    let l_ = l.cbrt();
200    let m_ = m.cbrt();
201    let s_ = s.cbrt();
202
203    let big_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
204    let big_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
205    let big_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
206    (big_l, big_a, big_b)
207}
208
209fn oklab_to_linear_srgb(big_l: f64, big_a: f64, big_b: f64) -> (f64, f64, f64) {
210    let l_ = big_l + 0.3963377774 * big_a + 0.2158037573 * big_b;
211    let m_ = big_l - 0.1055613458 * big_a - 0.0638541728 * big_b;
212    let s_ = big_l - 0.0894841775 * big_a - 1.2914855480 * big_b;
213
214    let l = l_ * l_ * l_;
215    let m = m_ * m_ * m_;
216    let s = s_ * s_ * s_;
217
218    let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
219    let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
220    let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
221    (r, g, b)
222}
223
224// --- OKLab <-> OKLCH ----------------------------------------------
225
226fn oklab_to_oklch(l: f64, a: f64, b: f64) -> (f64, f64, f64) {
227    let c = (a * a + b * b).sqrt();
228    let h_rad = b.atan2(a);
229    let mut h_deg = h_rad.to_degrees();
230    if h_deg < 0.0 {
231        h_deg += 360.0;
232    }
233    (l, c, h_deg)
234}
235
236fn oklch_to_oklab(l: f64, c: f64, h: f64) -> (f64, f64, f64) {
237    let h_rad = h.to_radians();
238    let a = c * h_rad.cos();
239    let b = c * h_rad.sin();
240    (l, a, b)
241}
242
243// --- full pipeline shortcuts --------------------------------------
244
245fn srgb_to_oklch(srgb: [f64; 3]) -> Color {
246    let r = srgb_decode(srgb[0]);
247    let g = srgb_decode(srgb[1]);
248    let b = srgb_decode(srgb[2]);
249    let (ll, la, lb) = linear_srgb_to_oklab(r, g, b);
250    let (l, c, h) = oklab_to_oklch(ll, la, lb);
251    Color { l, c, h }
252}
253
254fn oklch_to_srgb(color: Color) -> [f64; 3] {
255    let (ll, la, lb) = oklch_to_oklab(color.l, color.c, color.h);
256    let (r, g, b) = oklab_to_linear_srgb(ll, la, lb);
257    [srgb_encode(r), srgb_encode(g), srgb_encode(b)]
258}
259
260/// Linear-sRGB triple in the engine's gamma-decoded space. Exposed
261/// `pub(crate)` so `contrast.rs` can compute relative luminance
262/// without re-doing the conversion in two places.
263pub(crate) fn linear_srgb_of(color: &Color) -> [f64; 3] {
264    let (ll, la, lb) = oklch_to_oklab(color.l, color.c, color.h);
265    let (r, g, b) = oklab_to_linear_srgb(ll, la, lb);
266    [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]
267}
268
269/// Shortest angular distance between two hues, in degrees `0.0..=180.0`.
270/// Used by §7 to gap brand vs state colors and by §6 to score role fit.
271pub fn hue_distance(a: f64, b: f64) -> f64 {
272    let d = (a - b).abs() % 360.0;
273    if d > 180.0 {
274        360.0 - d
275    } else {
276        d
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn approx(a: f64, b: f64, eps: f64) -> bool {
285        (a - b).abs() < eps
286    }
287
288    #[test]
289    fn parse_six_digit_hex() {
290        let c = Color::from_hex("#3f6089").unwrap();
291        // Sanity check: a mid-blue should sit in the mid lightness
292        // band, carry visible chroma, and have a blue-leaning hue.
293        assert!(c.l > 0.30 && c.l < 0.65, "L out of mid band: {}", c.l);
294        assert!(c.c > 0.05);
295        assert!(c.h > 240.0 && c.h < 280.0, "got H={}", c.h);
296    }
297
298    #[test]
299    fn parse_three_digit_hex_expands() {
300        let short = Color::from_hex("#f00").unwrap();
301        let long = Color::from_hex("#ff0000").unwrap();
302        // f -> ff, 0 -> 00 — must round-trip to identical OKLCH.
303        assert!(approx(short.l, long.l, 1e-9));
304        assert!(approx(short.c, long.c, 1e-9));
305        assert!(approx(short.h, long.h, 1e-9));
306    }
307
308    #[test]
309    fn round_trip_hex_to_hex() {
310        for hex in [
311            "#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#3f6089", "#0d9488",
312        ] {
313            let c = Color::from_hex(hex).unwrap();
314            assert_eq!(c.to_hex(), hex, "round trip lost {hex}");
315        }
316    }
317
318    #[test]
319    fn bad_input_returns_error_no_panic() {
320        assert_eq!(Color::from_hex(""), Err(ColorError::MissingHash));
321        assert_eq!(Color::from_hex("3f6089"), Err(ColorError::MissingHash));
322        assert_eq!(Color::from_hex("#12"), Err(ColorError::BadLength));
323        assert_eq!(Color::from_hex("#zzzzzz"), Err(ColorError::BadDigit));
324    }
325
326    #[test]
327    fn mix_halfway_is_perceptually_centered() {
328        let black = Color::from_hex("#000000").unwrap();
329        let white = Color::from_hex("#ffffff").unwrap();
330        let mid = black.mix(&white, 0.5);
331        // OKLab L of mid gray should sit ~0.5 — half of pure white's 1.0.
332        assert!(approx(mid.l, 0.5, 0.02), "got L={}", mid.l);
333        assert!(mid.c < 1e-6, "mid of two grays should be achromatic");
334    }
335
336    #[test]
337    fn mix_self_is_self() {
338        let c = Color::from_hex("#0d9488").unwrap();
339        let m = c.mix(&Color::from_hex("#ffffff").unwrap(), 0.0);
340        assert_eq!(c.to_hex(), m.to_hex());
341    }
342
343    #[test]
344    fn out_of_gamut_clamps_not_wraps() {
345        // Very high chroma at L≈0.5 — sits outside sRGB. Must clamp
346        // each channel, not wrap into a different hue.
347        let oog = Color::from_oklch(0.5, 0.35, 30.0);
348        let hex = oog.to_hex();
349        for ch in (hex.as_bytes()[1..]).chunks(2) {
350            let s = std::str::from_utf8(ch).unwrap();
351            assert!(u8::from_str_radix(s, 16).is_ok());
352        }
353    }
354
355    #[test]
356    fn hue_distance_wraps_correctly() {
357        assert!((hue_distance(10.0, 350.0) - 20.0).abs() < 1e-9);
358        assert!((hue_distance(0.0, 180.0) - 180.0).abs() < 1e-9);
359        assert!((hue_distance(45.0, 45.0)).abs() < 1e-9);
360    }
361}