use gpui::{Hsla, Rgba}; /// Produces a readable text color for a given background, subtly tinted by the/// background's own hue using the OKLCH color space.////// The result keeps ~15% of the background's chroma so the text feels/// harmonious with its surroundings rather than a flat black or white./// Lightness is set to ensure readable contrast against the background.pub fn text_color_for_background(background: Hsla) -> Hsla { let rgba = Rgba::from(background); let r_lin = srgb_to_linear(rgba.r); let g_lin = srgb_to_linear(rgba.g); let b_lin = srgb_to_linear(rgba.b); let (_, ok_a, ok_b) = linear_rgb_to_oklab(r_lin, g_lin, b_lin); let chroma = (ok_a * ok_a + ok_b * ok_b).sqrt(); let hue = ok_b.atan2(ok_a); let bg_luminance = relative_luminance(rgba); let text_l = if bg_luminance > 0.18 { 0.18 } else { 0.96 }; let text_c = chroma * 0.15; let build = |c: f32| -> Rgba { let (tr, tg, tb) = oklab_to_linear_rgb(text_l, c * hue.cos(), c * hue.sin()); Rgba { r: linear_to_srgb(tr.clamp(0.0, 1.0)), g: linear_to_srgb(tg.clamp(0.0, 1.0)), b: linear_to_srgb(tb.clamp(0.0, 1.0)), a: 1.0, } }; let meets_contrast = |fg: Rgba| contrast_ratio_between(bg_luminance, relative_luminance(fg)) >= 4.5; let candidate = build(text_c); let result = if meets_contrast(candidate) { candidate } else { // Binary search for the maximum chroma that still meets 4.5:1. let mut lo = 0.0_f32; let mut hi = text_c; for _ in 0..16 { let mid = (lo + hi) * 0.5; if meets_contrast(build(mid)) { lo = mid; } else { hi = mid; } } let best = build(lo); // Floating-point precision can leave the binary search result just // below the 4.5:1 threshold. Fall back to pure black or white. if meets_contrast(best) { best } else if bg_luminance > 0.18 { Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0, } } else { Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0, } } }; Hsla::from(result)} fn srgb_to_linear(c: f32) -> f32 { if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }} fn linear_to_srgb(c: f32) -> f32 { if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }} fn linear_rgb_to_oklab(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); ( 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, )} fn oklab_to_linear_rgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) { let l_ = l + 0.3963377774 * a + 0.2158037573 * b; let m_ = l - 0.1055613458 * a - 0.0638541728 * b; let s_ = l - 0.0894841775 * a - 1.2914855480 * b; ( 4.0767416621 * l_ * l_ * l_ - 3.3077115913 * m_ * m_ * m_ + 0.2309699292 * s_ * s_ * s_, -1.2684380046 * l_ * l_ * l_ + 2.6097574011 * m_ * m_ * m_ - 0.3413193965 * s_ * s_ * s_, -0.0041960863 * l_ * l_ * l_ - 0.7034186147 * m_ * m_ * m_ + 1.7076147010 * s_ * s_ * s_, )} fn relative_luminance(c: Rgba) -> f32 { 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)} fn contrast_ratio_between(luminance_a: f32, luminance_b: f32) -> f32 { let (lighter, darker) = if luminance_a > luminance_b { (luminance_a, luminance_b) } else { (luminance_b, luminance_a) }; (lighter + 0.05) / (darker + 0.05)} #[cfg(test)]fn wcag_contrast_ratio(a: Rgba, b: Rgba) -> f32 { contrast_ratio_between(relative_luminance(a), relative_luminance(b))} #[cfg(test)]mod tests { use super::*; use gpui::proptest::prelude::*; #[gpui::property_test] fn sufficient_contrast_for_any_opaque_background( #[strategy = Hsla::opaque_strategy()] bg: Hsla, ) -> Result<(), TestCaseError> { let text = text_color_for_background(bg); let ratio = wcag_contrast_ratio(Rgba::from(bg), Rgba::from(text)); prop_assert!( ratio >= 4.5, "WCAG AA contrast ratio {ratio:.2} < 4.5 for bg {bg:?} -> text {text:?}", ); Ok(()) }}
use gpui::{Hsla, Rgba};
/// Produces a readable text color for a given background, subtly tinted by the
/// background's own hue using the OKLCH color space.
///
/// The result keeps ~15% of the background's chroma so the text feels
/// harmonious with its surroundings rather than a flat black or white.
/// Lightness is set to ensure readable contrast against the background.
pub fn text_color_for_background(background: Hsla) -> Hsla {
let rgba = Rgba::from(background);
let r_lin = srgb_to_linear(rgba.r);
let g_lin = srgb_to_linear(rgba.g);
let b_lin = srgb_to_linear(rgba.b);
let (_, ok_a, ok_b) = linear_rgb_to_oklab(r_lin, g_lin, b_lin);
let chroma = (ok_a * ok_a + ok_b * ok_b).sqrt();
let hue = ok_b.atan2(ok_a);
let bg_luminance = relative_luminance(rgba);
let text_l = if bg_luminance > 0.18 { 0.18 } else { 0.96 };
let text_c = chroma * 0.15;
let build = |c: f32| -> Rgba {
let (tr, tg, tb) = oklab_to_linear_rgb(text_l, c * hue.cos(), c * hue.sin());
Rgba {
r: linear_to_srgb(tr.clamp(0.0, 1.0)),
g: linear_to_srgb(tg.clamp(0.0, 1.0)),
b: linear_to_srgb(tb.clamp(0.0, 1.0)),
a: 1.0,
}
};
let meets_contrast =
|fg: Rgba| contrast_ratio_between(bg_luminance, relative_luminance(fg)) >= 4.5;
let candidate = build(text_c);
let result = if meets_contrast(candidate) {
candidate
} else {
// Binary search for the maximum chroma that still meets 4.5:1.
let mut lo = 0.0_f32;
let mut hi = text_c;
for _ in 0..16 {
let mid = (lo + hi) * 0.5;
if meets_contrast(build(mid)) {
lo = mid;
} else {
hi = mid;
}
}
let best = build(lo);
// Floating-point precision can leave the binary search result just
// below the 4.5:1 threshold. Fall back to pure black or white.
if meets_contrast(best) {
best
} else if bg_luminance > 0.18 {
Rgba {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}
} else {
Rgba {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
}
}
};
Hsla::from(result)
}
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.0031308 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
fn linear_rgb_to_oklab(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt();
let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt();
let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt();
(
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
)
}
fn oklab_to_linear_rgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) {
let l_ = l + 0.3963377774 * a + 0.2158037573 * b;
let m_ = l - 0.1055613458 * a - 0.0638541728 * b;
let s_ = l - 0.0894841775 * a - 1.2914855480 * b;
(
4.0767416621 * l_ * l_ * l_ - 3.3077115913 * m_ * m_ * m_ + 0.2309699292 * s_ * s_ * s_,
-1.2684380046 * l_ * l_ * l_ + 2.6097574011 * m_ * m_ * m_ - 0.3413193965 * s_ * s_ * s_,
-0.0041960863 * l_ * l_ * l_ - 0.7034186147 * m_ * m_ * m_ + 1.7076147010 * s_ * s_ * s_,
)
}
fn relative_luminance(c: Rgba) -> f32 {
0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
}
fn contrast_ratio_between(luminance_a: f32, luminance_b: f32) -> f32 {
let (lighter, darker) = if luminance_a > luminance_b {
(luminance_a, luminance_b)
} else {
(luminance_b, luminance_a)
};
(lighter + 0.05) / (darker + 0.05)
}
#[cfg(test)]
fn wcag_contrast_ratio(a: Rgba, b: Rgba) -> f32 {
contrast_ratio_between(relative_luminance(a), relative_luminance(b))
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::proptest::prelude::*;
#[gpui::property_test]
fn sufficient_contrast_for_any_opaque_background(
#[strategy = Hsla::opaque_strategy()] bg: Hsla,
) -> Result<(), TestCaseError> {
let text = text_color_for_background(bg);
let ratio = wcag_contrast_ratio(Rgba::from(bg), Rgba::from(text));
prop_assert!(
ratio >= 4.5,
"WCAG AA contrast ratio {ratio:.2} < 4.5 for bg {bg:?} -> text {text:?}",
);
Ok(())
}
}