216 lines
6.7 KiB
Rust
216 lines
6.7 KiB
Rust
//! Color math ported from bezel (`crates/theme/src/color.rs`, MIT License,
|
|
//! Copyright (c) 2026 bezel contributors): the oklch → sRGB → HSL converters
|
|
//! behind the token constructors, WCAG contrast for the palette tests, and
|
|
//! small paint helpers.
|
|
|
|
use gpui::{Hsla, hsla};
|
|
|
|
/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
|
|
/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
|
|
/// float-noise saturation).
|
|
pub fn neutral(lightness: f32) -> Hsla {
|
|
let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
|
|
hsla(0.0, 0.0, v, 1.0)
|
|
}
|
|
|
|
/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
|
|
/// — for surfaces matched against reference samples.
|
|
pub fn grey(value: u8) -> Hsla {
|
|
hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
|
|
}
|
|
|
|
/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
|
|
pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
|
|
let [r, g, b] = oklch_to_srgb(l, c, h_deg);
|
|
let (h, s, l) = rgb_to_hsl(r, g, b);
|
|
hsla(h, s, l, 1.0)
|
|
}
|
|
|
|
/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
|
|
/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
|
|
pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
|
let [r, g, b] = oklch_to_linear(l, c, h_deg);
|
|
[gamma_encode(r), gamma_encode(g), gamma_encode(b)]
|
|
}
|
|
|
|
/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the
|
|
/// display cannot make, which is what [`fit_chroma`] tests for.
|
|
fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
|
let h = h_deg.to_radians();
|
|
let a = c * h.cos();
|
|
let b = c * h.sin();
|
|
|
|
// OKLab → LMS (cube roots undone)
|
|
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
|
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
|
|
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
|
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
|
|
|
// LMS → linear sRGB
|
|
let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
|
|
let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
|
|
let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
|
|
|
|
[r, g, b]
|
|
}
|
|
|
|
fn gamma_encode(x: f32) -> f32 {
|
|
let x = x.clamp(0.0, 1.0);
|
|
if x <= 0.003_130_8 {
|
|
12.92 * x
|
|
} else {
|
|
1.055 * x.powf(1.0 / 2.4) - 0.055
|
|
}
|
|
}
|
|
|
|
fn gamma_decode(x: f32) -> f32 {
|
|
let x = x.clamp(0.0, 1.0);
|
|
if x <= 0.040_449_936 {
|
|
x / 12.92
|
|
} else {
|
|
((x + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
}
|
|
|
|
/// The oklch lightness of an achromatic tone: [`neutral`] inverted.
|
|
///
|
|
/// Only greys round-trip through this. A grey's three channels are equal, and
|
|
/// the three LMS matrix rows each sum to 1, so the whole OKLab transform
|
|
/// collapses to one cube root — no inverse matrix needed.
|
|
pub fn lightness(color: Hsla) -> f32 {
|
|
gamma_decode(color.l).cbrt()
|
|
}
|
|
|
|
/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`.
|
|
///
|
|
/// Near black and near white the gamut is a needle: asking for a mid-ramp
|
|
/// chroma there produces an out-of-range component, and clamping it per channel
|
|
/// shifts the hue instead of dropping the saturation.
|
|
fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 {
|
|
let fits = |c: f32| {
|
|
oklch_to_linear(l, c, hue)
|
|
.iter()
|
|
.all(|x| (-1e-4..=1.0 + 1e-4).contains(x))
|
|
};
|
|
if fits(chroma) {
|
|
return chroma;
|
|
}
|
|
let (mut lo, mut hi) = (0.0, chroma);
|
|
for _ in 0..24 {
|
|
let mid = 0.5 * (lo + hi);
|
|
if fits(mid) { lo = mid } else { hi = mid }
|
|
}
|
|
lo
|
|
}
|
|
|
|
/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its
|
|
/// lightness can hold. Alpha rides through untouched.
|
|
pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla {
|
|
if chroma <= 0.0 {
|
|
return color;
|
|
}
|
|
let l = lightness(color);
|
|
let mut out = oklch(l, fit_chroma(l, chroma, hue), hue);
|
|
out.a = color.a;
|
|
out
|
|
}
|
|
|
|
/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
|
|
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
|
let max = r.max(g).max(b);
|
|
let min = r.min(g).min(b);
|
|
let l = (max + min) / 2.0;
|
|
let delta = max - min;
|
|
if delta < f32::EPSILON {
|
|
return (0.0, 0.0, l);
|
|
}
|
|
let s = if l > 0.5 {
|
|
delta / (2.0 - max - min)
|
|
} else {
|
|
delta / (max + min)
|
|
};
|
|
let h = if (max - r).abs() < f32::EPSILON {
|
|
((g - b) / delta).rem_euclid(6.0)
|
|
} else if (max - g).abs() < f32::EPSILON {
|
|
(b - r) / delta + 2.0
|
|
} else {
|
|
(r - g) / delta + 4.0
|
|
} / 6.0;
|
|
(h, s, l)
|
|
}
|
|
|
|
/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
|
|
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
|
if s <= f32::EPSILON {
|
|
return [l, l, l];
|
|
}
|
|
let q = if l < 0.5 {
|
|
l * (1.0 + s)
|
|
} else {
|
|
l + s - l * s
|
|
};
|
|
let p = 2.0 * l - q;
|
|
let hue = |mut t: f32| {
|
|
t = t.rem_euclid(1.0);
|
|
if t < 1.0 / 6.0 {
|
|
p + (q - p) * 6.0 * t
|
|
} else if t < 0.5 {
|
|
q
|
|
} else if t < 2.0 / 3.0 {
|
|
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
|
} else {
|
|
p
|
|
}
|
|
};
|
|
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
|
|
}
|
|
|
|
/// WCAG 2.1 relative luminance of an opaque color.
|
|
pub fn relative_luminance(color: Hsla) -> f32 {
|
|
let lin = |c: f32| {
|
|
if c <= 0.040_45 {
|
|
c / 12.92
|
|
} else {
|
|
((c + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
};
|
|
let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
|
|
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
}
|
|
|
|
/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
|
|
///
|
|
/// Used by the palette tests to prove each light token reproduces the contrast
|
|
/// its dark counterpart had, rather than merely looking plausible.
|
|
pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
|
|
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
|
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
|
(hi + 0.05) / (lo + 0.05)
|
|
}
|
|
|
|
/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
|
|
/// opaque result — the color the eye actually receives.
|
|
pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
|
|
let a = fg.a.clamp(0.0, 1.0);
|
|
let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
|
|
let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
|
|
let (h, s, l) = rgb_to_hsl(
|
|
fr * a + br * (1.0 - a),
|
|
fg_ * a + bg_ * (1.0 - a),
|
|
fb * a + bb * (1.0 - a),
|
|
);
|
|
hsla(h, s, l, 1.0)
|
|
}
|
|
|
|
/// Linear per-component mix of two colors.
|
|
pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
|
|
let t = t.clamp(0.0, 1.0);
|
|
let lerp = |x: f32, y: f32| x + (y - x) * t;
|
|
hsla(
|
|
lerp(a.h, b.h),
|
|
lerp(a.s, b.s),
|
|
lerp(a.l, b.l),
|
|
lerp(a.a, b.a),
|
|
)
|
|
}
|