Skip to main content

theme/
color.rs

1//! Color math: the oklch → sRGB → HSL converters behind the token constructors,
2//! WCAG contrast for the palette tests, and small paint helpers.
3
4use gpui::{Hsla, hsla};
5
6/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
7/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
8/// float-noise saturation).
9pub fn neutral(lightness: f32) -> Hsla {
10    let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
11    hsla(0.0, 0.0, v, 1.0)
12}
13
14/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
15/// — for surfaces matched against reference-screenshot samples.
16pub fn grey(value: u8) -> Hsla {
17    hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
18}
19
20/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
21pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
22    let [r, g, b] = oklch_to_srgb(l, c, h_deg);
23    let (h, s, l) = rgb_to_hsl(r, g, b);
24    hsla(h, s, l, 1.0)
25}
26
27/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
28/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
29pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
30    let h = h_deg.to_radians();
31    let a = c * h.cos();
32    let b = c * h.sin();
33
34    // OKLab → LMS (cube roots undone)
35    let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
36    let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
37    let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
38    let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
39
40    // LMS → linear sRGB
41    let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
42    let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
43    let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
44
45    [gamma_encode(r), gamma_encode(g), gamma_encode(b)]
46}
47
48fn gamma_encode(x: f32) -> f32 {
49    let x = x.clamp(0.0, 1.0);
50    if x <= 0.003_130_8 {
51        12.92 * x
52    } else {
53        1.055 * x.powf(1.0 / 2.4) - 0.055
54    }
55}
56
57/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
58pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
59    let max = r.max(g).max(b);
60    let min = r.min(g).min(b);
61    let l = (max + min) / 2.0;
62    let delta = max - min;
63    if delta < f32::EPSILON {
64        return (0.0, 0.0, l);
65    }
66    let s = if l > 0.5 {
67        delta / (2.0 - max - min)
68    } else {
69        delta / (max + min)
70    };
71    let h = if (max - r).abs() < f32::EPSILON {
72        ((g - b) / delta).rem_euclid(6.0)
73    } else if (max - g).abs() < f32::EPSILON {
74        (b - r) / delta + 2.0
75    } else {
76        (r - g) / delta + 4.0
77    } / 6.0;
78    (h, s, l)
79}
80
81/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
82pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
83    if s <= f32::EPSILON {
84        return [l, l, l];
85    }
86    let q = if l < 0.5 {
87        l * (1.0 + s)
88    } else {
89        l + s - l * s
90    };
91    let p = 2.0 * l - q;
92    let hue = |mut t: f32| {
93        t = t.rem_euclid(1.0);
94        if t < 1.0 / 6.0 {
95            p + (q - p) * 6.0 * t
96        } else if t < 0.5 {
97            q
98        } else if t < 2.0 / 3.0 {
99            p + (q - p) * (2.0 / 3.0 - t) * 6.0
100        } else {
101            p
102        }
103    };
104    [hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
105}
106
107/// WCAG 2.1 relative luminance of an opaque color.
108pub fn relative_luminance(color: Hsla) -> f32 {
109    let lin = |c: f32| {
110        if c <= 0.040_45 {
111            c / 12.92
112        } else {
113            ((c + 0.055) / 1.055).powf(2.4)
114        }
115    };
116    let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
117    0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
118}
119
120/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
121///
122/// Used by the palette tests to prove each light token reproduces the contrast
123/// its dark counterpart had, rather than merely looking plausible.
124pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
125    let (la, lb) = (relative_luminance(a), relative_luminance(b));
126    let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
127    (hi + 0.05) / (lo + 0.05)
128}
129
130/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
131/// opaque result — the color the eye actually receives.
132pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
133    let a = fg.a.clamp(0.0, 1.0);
134    let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
135    let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
136    let (h, s, l) = rgb_to_hsl(
137        fr * a + br * (1.0 - a),
138        fg_ * a + bg_ * (1.0 - a),
139        fb * a + bb * (1.0 - a),
140    );
141    hsla(h, s, l, 1.0)
142}
143
144/// Linear per-component mix of two colors (paint helper for the gradient spinner).
145pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
146    let t = t.clamp(0.0, 1.0);
147    let lerp = |x: f32, y: f32| x + (y - x) * t;
148    // Mix through hue naively — both spinner endpoints sit close enough on the
149    // wheel that shortest-arc handling isn't needed for our palette.
150    hsla(
151        lerp(a.h, b.h),
152        lerp(a.s, b.s),
153        lerp(a.l, b.l),
154        lerp(a.a, b.a),
155    )
156}