//! 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 ); } }