theme: build out the theme engine core with resolve pipeline
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user