274 lines
8.3 KiB
Rust
274 lines
8.3 KiB
Rust
//! OKLCH color math for accent-ramp derivation and contrast-aware
|
|
//! foreground selection. Matrices follow Björn Ottosson's OKLab reference.
|
|
// The reference matrices are quoted at their published precision; f32
|
|
// truncation is deliberate, not a mistake.
|
|
#![allow(clippy::excessive_precision)]
|
|
|
|
use gpui::{Hsla, Rgba};
|
|
|
|
/// A color in the OKLCH space (perceptually uniform):
|
|
/// lightness, chroma, hue in degrees, plus the alpha carried through unchanged.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Oklch {
|
|
pub l: f32,
|
|
pub c: f32,
|
|
pub h: f32,
|
|
pub a: f32,
|
|
}
|
|
|
|
impl Oklch {
|
|
pub fn from_hsla(color: Hsla) -> Self {
|
|
let Rgba { r, g, b, a } = Rgba::from(color);
|
|
let (l, c, h) = rgb_to_oklch(r, g, b);
|
|
Self { l, c, h, a }
|
|
}
|
|
|
|
pub fn to_hsla(self) -> Hsla {
|
|
let (r, g, b) = oklch_to_rgb(self.l, self.c, self.h);
|
|
Hsla::from(Rgba { r, g, b, a: self.a })
|
|
}
|
|
|
|
/// Shifts lightness by `amount` (positive is lighter),
|
|
/// and scales chroma by `chroma_scale`, preserving hue and alpha.
|
|
pub fn shifted(self, amount: f32, chroma_scale: f32) -> Self {
|
|
Self {
|
|
l: (self.l + amount).clamp(0.0, 1.0),
|
|
c: (self.c * chroma_scale).max(0.0),
|
|
h: self.h,
|
|
a: self.a,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mixes `a` toward `b` by `t` in OKLCH, taking the shortest arc between hues.
|
|
///
|
|
/// `t = 0` returns `a`, `t = 1` returns `b`.
|
|
pub fn mix(a: Oklch, b: Oklch, t: f32) -> Oklch {
|
|
let mut dh = b.h - a.h;
|
|
|
|
if dh > 180.0 {
|
|
dh -= 360.0;
|
|
} else if dh < -180.0 {
|
|
dh += 360.0;
|
|
}
|
|
|
|
Oklch {
|
|
l: a.l + (b.l - a.l) * t,
|
|
c: a.c + (b.c - a.c) * t,
|
|
h: (a.h + dh * t + 360.0) % 360.0,
|
|
a: a.a + (b.a - a.a) * t,
|
|
}
|
|
}
|
|
|
|
fn rgb_to_oklch(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
|
let (r, g, b) = (linearize(r), linearize(g), linearize(b));
|
|
let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
|
|
let (l_, m_, s_) = (cbrt(l), cbrt(m), cbrt(s));
|
|
let lab_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
let lab_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
let lab_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
|
|
let c = (lab_a * lab_a + lab_b * lab_b).sqrt();
|
|
let mut h = lab_b.atan2(lab_a).to_degrees();
|
|
|
|
if h < 0.0 {
|
|
h += 360.0;
|
|
}
|
|
|
|
(lab_l, c, h)
|
|
}
|
|
|
|
fn oklch_to_rgb(l: f32, c: f32, h: f32) -> (f32, f32, f32) {
|
|
let hr = h.to_radians();
|
|
let lab_a = c * hr.cos();
|
|
let lab_b = c * hr.sin();
|
|
|
|
let l_ = l + 0.3963377774 * lab_a + 0.2158037573 * lab_b;
|
|
let m_ = l - 0.1055613458 * lab_a - 0.0638541728 * lab_b;
|
|
let s_ = l - 0.0894841775 * lab_a - 1.2914855480 * lab_b;
|
|
|
|
let l = l_ * l_ * l_;
|
|
let m = m_ * m_ * m_;
|
|
let s = s_ * s_ * s_;
|
|
|
|
let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
|
|
(delinearize(r), delinearize(g), delinearize(b))
|
|
}
|
|
|
|
fn linearize(c: f32) -> f32 {
|
|
if c <= 0.04045 {
|
|
c / 12.92
|
|
} else {
|
|
((c + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
}
|
|
|
|
fn delinearize(c: f32) -> f32 {
|
|
if c <= 0.0031308 {
|
|
c * 12.92
|
|
} else {
|
|
1.055 * c.powf(1.0 / 2.4) - 0.055
|
|
}
|
|
}
|
|
|
|
fn cbrt(v: f32) -> f32 {
|
|
if v >= 0.0 { v.cbrt() } else { -(-v).cbrt() }
|
|
}
|
|
|
|
/// Calculate the foreground color that provides sufficient contrast against the background.
|
|
pub fn contrast_foreground(background: Hsla) -> Hsla {
|
|
const MIN_CONTRAST: f32 = 3.0;
|
|
|
|
let Rgba { r, g, b, .. } = Rgba::from(background);
|
|
|
|
let luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
let white_contrast = 1.05 / (luminance + 0.05);
|
|
let black_contrast = (luminance + 0.05) / 0.05;
|
|
|
|
let white = Hsla::from(Rgba {
|
|
r: 1.0,
|
|
g: 1.0,
|
|
b: 1.0,
|
|
a: 1.0,
|
|
});
|
|
|
|
let black = Hsla::from(Rgba {
|
|
r: 0.0,
|
|
g: 0.0,
|
|
b: 0.0,
|
|
a: 1.0,
|
|
});
|
|
|
|
if white_contrast >= MIN_CONTRAST || white_contrast >= black_contrast {
|
|
white
|
|
} else {
|
|
black
|
|
}
|
|
}
|
|
|
|
/// The state shades components paint from for one accent color.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct AccentShades {
|
|
pub base: Hsla,
|
|
pub hover: Hsla,
|
|
pub pressed: Hsla,
|
|
pub subtle: Hsla,
|
|
pub foreground: Hsla,
|
|
}
|
|
|
|
/// Derives hover/pressed/subtle shades for an accent against its background.
|
|
///
|
|
/// Where a platform states ramps (WinUI, KDE) those are used instead,
|
|
pub fn derive_shades(accent: Hsla, background: Hsla, is_dark: bool) -> AccentShades {
|
|
let accent_ok = Oklch::from_hsla(accent);
|
|
let step = if is_dark { 0.05 } else { -0.05 };
|
|
let bg = Oklch::from_hsla(background);
|
|
AccentShades {
|
|
base: accent,
|
|
hover: accent_ok.shifted(step, 1.05).to_hsla(),
|
|
pressed: accent_ok.shifted(step * 2.0, 1.1).to_hsla(),
|
|
subtle: mix(accent_ok, bg, 0.88).to_hsla(),
|
|
foreground: contrast_foreground(accent),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use gpui::rgb;
|
|
|
|
fn hsla(hex: u32) -> Hsla {
|
|
Hsla::from(rgb(hex))
|
|
}
|
|
|
|
#[test]
|
|
fn white_and_black_have_extreme_lightness_and_no_chroma() {
|
|
let white = Oklch::from_hsla(hsla(0xffffff));
|
|
assert!((white.l - 1.0).abs() < 0.001, "white L = {}", white.l);
|
|
assert!(white.c < 0.001, "white C = {}", white.c);
|
|
|
|
let black = Oklch::from_hsla(hsla(0x000000));
|
|
assert!(black.l.abs() < 0.001, "black L = {}", black.l);
|
|
}
|
|
|
|
#[test]
|
|
fn oklch_round_trips_within_one_step_of_eight_bit_color() {
|
|
for hex in [0x2563eb, 0xdc2626, 0x7aa2f7, 0x1a1b26, 0xf7768e] {
|
|
let ok = Oklch::from_hsla(hsla(hex));
|
|
let Rgba { r, g, b, .. } = Rgba::from(ok.to_hsla());
|
|
let (er, eg, eb) = {
|
|
let expected = Rgba::from(hsla(hex));
|
|
(expected.r, expected.g, expected.b)
|
|
};
|
|
assert!((r - er).abs() < 1.0 / 255.0, "{hex:06x} r {r} vs {er}");
|
|
assert!((g - eg).abs() < 1.0 / 255.0, "{hex:06x} g {g} vs {eg}");
|
|
assert!((b - eb).abs() < 1.0 / 255.0, "{hex:06x} b {b} vs {eb}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shades_move_toward_the_background_in_light_mode() {
|
|
let accent = hsla(0x2563eb);
|
|
let background = hsla(0xffffff);
|
|
let shades = derive_shades(accent, background, false);
|
|
let hover_l = Oklch::from_hsla(shades.hover).l;
|
|
let base_l = Oklch::from_hsla(accent).l;
|
|
assert!(
|
|
hover_l < base_l,
|
|
"light-mode hover darkens: {hover_l} vs {base_l}"
|
|
);
|
|
|
|
let dark_shades = derive_shades(accent, hsla(0x09090b), true);
|
|
let dark_hover_l = Oklch::from_hsla(dark_shades.hover).l;
|
|
assert!(dark_hover_l > base_l, "dark-mode hover lightens");
|
|
}
|
|
|
|
#[test]
|
|
fn subtle_stays_between_accent_and_background() {
|
|
let accent = Oklch::from_hsla(hsla(0x2563eb));
|
|
let background = Oklch::from_hsla(hsla(0xffffff));
|
|
let subtle = Oklch::from_hsla(derive_shades(hsla(0x2563eb), hsla(0xffffff), false).subtle);
|
|
assert!(subtle.l > accent.l && subtle.l < background.l);
|
|
}
|
|
|
|
#[test]
|
|
fn contrast_foreground_follows_native_fill_conventions() {
|
|
// Dark fills get white.
|
|
assert_eq!(contrast_foreground(hsla(0x101010)), hsla(0xffffff));
|
|
// macOS blue accent keeps white, like AppKit, even though black has
|
|
// the higher raw WCAG ratio.
|
|
assert_eq!(contrast_foreground(hsla(0x007aff)), hsla(0xffffff));
|
|
// Light fills — white fails the 3:1 bar and black wins.
|
|
assert_eq!(contrast_foreground(hsla(0xefefef)), hsla(0x000000));
|
|
assert_eq!(contrast_foreground(hsla(0xffd60a)), hsla(0x000000));
|
|
}
|
|
|
|
#[test]
|
|
fn mix_takes_the_shortest_arc_around_the_hue_wheel() {
|
|
let a = Oklch {
|
|
l: 0.5,
|
|
c: 0.1,
|
|
h: 350.0,
|
|
a: 1.0,
|
|
};
|
|
let b = Oklch {
|
|
l: 0.5,
|
|
c: 0.1,
|
|
h: 10.0,
|
|
a: 1.0,
|
|
};
|
|
let mid = mix(a, b, 0.5);
|
|
assert!(
|
|
mid.h < 10.0 || mid.h > 350.0,
|
|
"mid hue should cross 0/360, got {}",
|
|
mid.h
|
|
);
|
|
}
|
|
}
|