use serde::{Deserialize, Serialize};
use crate::gradient::Rgb;
use crate::mxc::wire::{Colors, Hex};
pub fn relative_luminance(c: Rgb) -> f64 {
fn linearize(channel: u8) -> f64 {
let c = channel as f64 / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * linearize(c.r) + 0.7152 * linearize(c.g) + 0.0722 * linearize(c.b)
}
pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 {
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)
}
pub fn is_dark(c: Rgb) -> bool {
relative_luminance(c) < 0.5
}
pub const AA: f64 = 4.5;
pub const INK: Rgb = Rgb::new(0x0b, 0x0b, 0x0b);
pub const PAPER: Rgb = Rgb::new(0xff, 0xff, 0xff);
pub fn best_on(bg: Rgb) -> Rgb {
if contrast_ratio(bg, INK) >= contrast_ratio(bg, PAPER) {
INK
} else {
PAPER
}
}
pub fn best_on_preferring(bg: Rgb, preferred: Rgb) -> Rgb {
if contrast_ratio(bg, preferred) >= AA {
preferred
} else {
best_on(bg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Contrast {
pub on_primary: Hex,
pub on_secondary: Hex,
pub on_accent: Hex,
pub on_background: Hex,
}
impl Contrast {
pub fn compute(colors: &Colors) -> Contrast {
Contrast {
on_primary: Hex(best_on(colors.primary.into())),
on_secondary: Hex(best_on(colors.secondary.into())),
on_accent: Hex(best_on(colors.accent.into())),
on_background: Hex(best_on_preferring(
colors.background.into(),
colors.text.into(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const BLACK: Rgb = Rgb::new(0, 0, 0);
const WHITE: Rgb = Rgb::new(255, 255, 255);
const EPS: f64 = 1e-9;
#[test]
fn luminance_endpoints_are_exact() {
assert!((relative_luminance(WHITE) - 1.0).abs() < EPS);
assert!(relative_luminance(BLACK).abs() < EPS);
}
#[test]
fn black_on_white_is_the_maximum_21_to_1() {
assert!((contrast_ratio(BLACK, WHITE) - 21.0).abs() < 0.01);
}
#[test]
fn a_color_against_itself_is_1_to_1() {
for c in [BLACK, WHITE, Rgb::new(0x64, 0xe0, 0xd0), INK, PAPER] {
assert!(
(contrast_ratio(c, c) - 1.0).abs() < EPS,
"{c:?} against itself must be 1.0"
);
}
}
#[test]
fn contrast_ratio_is_symmetric() {
let pairs = [
(BLACK, WHITE),
(Rgb::new(0x08, 0x10, 0x18), Rgb::new(0xd8, 0xef, 0xff)),
(Rgb::new(0x76, 0x76, 0x76), Rgb::new(0xf4, 0xaa, 0x48)),
];
for (a, b) in pairs {
assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < EPS);
}
}
#[test]
fn wcag_boundary_grey_brackets_aa() {
let pass = contrast_ratio(Rgb::new(0x76, 0x76, 0x76), WHITE);
let fail = contrast_ratio(Rgb::new(0x77, 0x77, 0x77), WHITE);
assert!(pass >= AA, "#767676 on white must pass AA, got {pass}");
assert!(fail < AA, "#777777 on white must fail AA, got {fail}");
}
#[test]
fn is_dark_splits_on_luminance() {
assert!(is_dark(Rgb::new(0x08, 0x10, 0x18)));
assert!(!is_dark(WHITE));
}
#[test]
fn best_on_meets_aa_against_a_bright_token() {
let bg = Rgb::new(0x64, 0xe0, 0xd0);
let fg = best_on(bg);
assert!(
contrast_ratio(bg, fg) >= AA,
"best_on({bg:?}) = {fg:?} only reached {}",
contrast_ratio(bg, fg)
);
}
#[test]
fn best_on_preferring_keeps_a_passing_preference() {
let bg = Rgb::new(0x08, 0x10, 0x18);
let text = Rgb::new(0xd8, 0xef, 0xff);
assert_eq!(best_on_preferring(bg, text), text);
}
#[test]
fn best_on_preferring_rejects_a_failing_preference() {
let bg = BLACK;
let text = Rgb::new(0x10, 0x10, 0x10);
let got = best_on_preferring(bg, text);
assert_ne!(got, text);
assert_eq!(got, best_on(bg));
assert!(contrast_ratio(bg, got) >= AA);
}
#[test]
fn compute_prefers_theme_text_only_for_background() {
let colors: Colors = serde_json::from_str(
r##"{
"primary":"#64e0d0","secondary":"#4a9fd8","accent":"#f4aa48",
"error":"#e05561","warning":"#d9a441","success":"#61c766",
"info":"#64e0d0","text":"#d8efff","text_muted":"#7a90a4",
"background":"#081018","background_panel":"#101d2a",
"background_element":"#18293a","border":"#22374a",
"border_active":"#42d9d0","border_subtle":"#182838",
"border_dimmest":"#101c28"
}"##,
)
.unwrap();
let c = Contrast::compute(&colors);
assert_eq!(
c.on_background, colors.text,
"tinted text passes AA, keep it"
);
assert_eq!(c.on_primary, Hex(INK), "#64e0d0 is a light fill");
assert_eq!(c.on_accent, Hex(INK), "#f4aa48 is a light fill");
}
}