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/// WCAG AA for body text: the most contrast [`kept`] asks text to keep.
7pub const LEGIBLE: f32 = 4.5;
8
9/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
10/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
11/// float-noise saturation).
12pub fn neutral(lightness: f32) -> Hsla {
13    let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
14    hsla(0.0, 0.0, v, 1.0)
15}
16
17/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
18/// — for surfaces matched against reference-screenshot samples.
19pub fn grey(value: u8) -> Hsla {
20    hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
21}
22
23/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
24pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
25    let [r, g, b] = oklch_to_srgb(l, c, h_deg);
26    let (h, s, l) = rgb_to_hsl(r, g, b);
27    hsla(h, s, l, 1.0)
28}
29
30/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
31/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
32pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
33    let [r, g, b] = oklch_to_linear(l, c, h_deg);
34    [gamma_encode(r), gamma_encode(g), gamma_encode(b)]
35}
36
37/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the
38/// display cannot make, which is what [`fit_chroma`] tests for.
39fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
40    let h = h_deg.to_radians();
41    oklab_to_linear(l, c * h.cos(), c * h.sin())
42}
43
44/// OKLab → *linear* sRGB, unclamped.
45fn oklab_to_linear(l: f32, a: f32, b: f32) -> [f32; 3] {
46    // OKLab → LMS (cube roots undone)
47    let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
48    let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
49    let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
50    let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
51
52    // LMS → linear sRGB
53    let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
54    let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
55    let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
56
57    [r, g, b]
58}
59
60fn gamma_encode(x: f32) -> f32 {
61    let x = x.clamp(0.0, 1.0);
62    if x <= 0.003_130_8 {
63        12.92 * x
64    } else {
65        1.055 * x.powf(1.0 / 2.4) - 0.055
66    }
67}
68
69fn gamma_decode(x: f32) -> f32 {
70    let x = x.clamp(0.0, 1.0);
71    if x <= 0.040_449_936 {
72        x / 12.92
73    } else {
74        ((x + 0.055) / 1.055).powf(2.4)
75    }
76}
77
78/// Linear sRGB → OKLab.
79fn linear_to_oklab([r, g, b]: [f32; 3]) -> [f32; 3] {
80    let l = (0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
81    let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
82    let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
83    [
84        0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
85        1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
86        0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
87    ]
88}
89
90/// `from` moved `t` of the way to `to` (0..1) in OKLab, alpha alongside.
91pub fn mix_oklab(from: Hsla, to: Hsla, t: f32) -> Hsla {
92    let t = t.clamp(0.0, 1.0);
93    let lab = |color: Hsla| {
94        let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
95        linear_to_oklab([gamma_decode(r), gamma_decode(g), gamma_decode(b)])
96    };
97    let (a, b) = (lab(from), lab(to));
98    let [l, x, y] = std::array::from_fn(|ix| a[ix] + (b[ix] - a[ix]) * t);
99    let [r, g, bl] = oklab_to_linear(l, x, y);
100    let (h, s, l) = rgb_to_hsl(gamma_encode(r), gamma_encode(g), gamma_encode(bl));
101    hsla(h, s, l, from.a + (to.a - from.a) * t)
102}
103
104/// The oklch lightness of an achromatic tone: [`neutral`] inverted.
105///
106/// Only greys round-trip through this. A grey's three channels are equal, and
107/// the three LMS matrix rows each sum to 1, so the whole OKLab transform
108/// collapses to one cube root — no inverse matrix needed.
109pub fn lightness(color: Hsla) -> f32 {
110    gamma_decode(color.l).cbrt()
111}
112
113/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`.
114///
115/// Near black and near white the gamut is a needle: asking for a mid-ramp
116/// chroma there produces an out-of-range component, and clamping it per channel
117/// shifts the hue instead of dropping the saturation. Tailwind's neutral ramps
118/// taper their chroma at both ends by hand for the same reason; here the taper
119/// is whatever the gamut allows, so no ramp has to be tabulated.
120fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 {
121    let fits = |c: f32| {
122        oklch_to_linear(l, c, hue)
123            .iter()
124            .all(|x| (-1e-4..=1.0 + 1e-4).contains(x))
125    };
126    if fits(chroma) {
127        return chroma;
128    }
129    let (mut lo, mut hi) = (0.0, chroma);
130    for _ in 0..24 {
131        let mid = 0.5 * (lo + hi);
132        if fits(mid) { lo = mid } else { hi = mid }
133    }
134    lo
135}
136
137/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its
138/// lightness can hold. Alpha rides through untouched.
139pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla {
140    if chroma <= 0.0 {
141        return color;
142    }
143    let l = lightness(color);
144    let mut out = oklch(l, fit_chroma(l, chroma, hue), hue);
145    out.a = color.a;
146    out
147}
148
149/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
150pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
151    let max = r.max(g).max(b);
152    let min = r.min(g).min(b);
153    let l = (max + min) / 2.0;
154    let delta = max - min;
155    if delta < f32::EPSILON {
156        return (0.0, 0.0, l);
157    }
158    let s = if l > 0.5 {
159        delta / (2.0 - max - min)
160    } else {
161        delta / (max + min)
162    };
163    let h = if (max - r).abs() < f32::EPSILON {
164        ((g - b) / delta).rem_euclid(6.0)
165    } else if (max - g).abs() < f32::EPSILON {
166        (b - r) / delta + 2.0
167    } else {
168        (r - g) / delta + 4.0
169    } / 6.0;
170    (h, s, l)
171}
172
173/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
174pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
175    if s <= f32::EPSILON {
176        return [l, l, l];
177    }
178    let q = if l < 0.5 {
179        l * (1.0 + s)
180    } else {
181        l + s - l * s
182    };
183    let p = 2.0 * l - q;
184    let hue = |mut t: f32| {
185        t = t.rem_euclid(1.0);
186        if t < 1.0 / 6.0 {
187            p + (q - p) * 6.0 * t
188        } else if t < 0.5 {
189            q
190        } else if t < 2.0 / 3.0 {
191            p + (q - p) * (2.0 / 3.0 - t) * 6.0
192        } else {
193            p
194        }
195    };
196    [hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
197}
198
199/// WCAG 2.1 relative luminance of an opaque color.
200pub fn relative_luminance(color: Hsla) -> f32 {
201    let lin = |c: f32| {
202        if c <= 0.040_45 {
203            c / 12.92
204        } else {
205            ((c + 0.055) / 1.055).powf(2.4)
206        }
207    };
208    let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
209    0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
210}
211
212/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
213///
214/// Used by the palette tests to prove each light token reproduces the contrast
215/// its dark counterpart had, rather than merely looking plausible.
216pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
217    let (la, lb) = (relative_luminance(a), relative_luminance(b));
218    let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
219    (hi + 0.05) / (lo + 0.05)
220}
221
222/// Whichever of `a` and `b` has the higher contrast ratio against `plate`,
223/// `a` on a tie.
224pub fn legible_on(plate: Hsla, a: Hsla, b: Hsla) -> Hsla {
225    if contrast_ratio(plate, a) >= contrast_ratio(plate, b) {
226        a
227    } else {
228        b
229    }
230}
231
232/// `layers`, each of which may be translucent, painted in order over the
233/// opaque `base`: the color the eye receives where they all overlap.
234pub fn stack(base: Hsla, layers: impl IntoIterator<Item = Hsla>) -> Hsla {
235    layers
236        .into_iter()
237        .fold(opaque(base), |under, layer| flatten(layer, under))
238}
239
240/// The contrast `text` keeps when something is painted under it: what it has
241/// over `page`, up to [`LEGIBLE`]. Faint text is held to its own contrast, not
242/// raised to body text's.
243pub fn kept(text: Hsla, page: Hsla) -> f32 {
244    let page = opaque(page);
245    contrast_ratio(flatten(text, page), page).min(LEGIBLE)
246}
247
248/// The colour to draw `text` in over `plate`. `surface` is what `text` was
249/// coloured for: the opaque `page` with the washes under it, a highlight mark
250/// or a find match. `plate` is what is painted under it now, a selection or a
251/// solid caret over that surface. `text` where it reads there at the better
252/// of the contrasts it [`kept`] on the page and on the surface, else the
253/// surface's own colour (inverse video) where that does, else whichever of
254/// black and white reads better.
255pub fn ink_over(text: Hsla, page: Hsla, surface: Hsla, plate: Hsla) -> Hsla {
256    let floor = kept(text, page).max(kept(text, surface));
257    let reads = |color: Hsla| contrast_ratio(flatten(color, plate), plate) >= floor;
258    let surface = opaque(surface);
259    match (reads(text), reads(surface)) {
260        (true, _) => text,
261        (false, true) => surface,
262        (false, false) => legible_on(plate, hsla(0.0, 0.0, 1.0, 1.0), hsla(0.0, 0.0, 0.0, 1.0)),
263    }
264}
265
266/// `color` at full alpha: a translucent surface measured as though nothing
267/// showed through it.
268fn opaque(color: Hsla) -> Hsla {
269    hsla(color.h, color.s, color.l, 1.0)
270}
271
272/// The color to draw `text` (which may be translucent) in over the opaque
273/// `plate`: `text` where it reads at `floor` there, else `text` with its
274/// lightness inverted where that does, else whichever of black and white reads
275/// better.
276pub fn legible(text: Hsla, plate: Hsla, floor: f32) -> Hsla {
277    let reads = |color: Hsla| contrast_ratio(flatten(color, plate), plate) >= floor;
278    if reads(text) {
279        return text;
280    }
281    let inverted = hsla(text.h, text.s, 1.0 - text.l, text.a);
282    if reads(inverted) {
283        return inverted;
284    }
285    legible_on(plate, hsla(0.0, 0.0, 1.0, 1.0), hsla(0.0, 0.0, 0.0, 1.0))
286}
287
288/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
289/// opaque result — the color the eye actually receives.
290pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
291    let a = fg.a.clamp(0.0, 1.0);
292    let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
293    let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
294    let (h, s, l) = rgb_to_hsl(
295        fr * a + br * (1.0 - a),
296        fg_ * a + bg_ * (1.0 - a),
297        fb * a + bb * (1.0 - a),
298    );
299    hsla(h, s, l, 1.0)
300}
301
302/// Composite `top` over `under`, both of which may be translucent: the one
303/// color that paints what the two painted in that order do, over anything.
304pub fn over(top: Hsla, under: Hsla) -> Hsla {
305    let (at, au) = (top.a.clamp(0.0, 1.0), under.a.clamp(0.0, 1.0));
306    let a = at + au * (1.0 - at);
307    if a <= 0.0 {
308        return hsla(0.0, 0.0, 0.0, 0.0);
309    }
310    let [tr, tg, tb] = hsl_to_rgb(top.h, top.s, top.l);
311    let [ur, ug, ub] = hsl_to_rgb(under.h, under.s, under.l);
312    let mix = |t: f32, u: f32| (t * at + u * au * (1.0 - at)) / a;
313    let (h, s, l) = rgb_to_hsl(mix(tr, ur), mix(tg, ug), mix(tb, ub));
314    hsla(h, s, l, a)
315}
316
317/// Linear per-component mix of two colors (paint helper for the gradient spinner).
318pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
319    let t = t.clamp(0.0, 1.0);
320    let lerp = |x: f32, y: f32| x + (y - x) * t;
321    // Mix through hue naively — both spinner endpoints sit close enough on the
322    // wheel that shortest-arc handling isn't needed for our palette.
323    hsla(
324        lerp(a.h, b.h),
325        lerp(a.s, b.s),
326        lerp(a.l, b.l),
327        lerp(a.a, b.a),
328    )
329}