use egui::Color32;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Oklch {
pub l: f32,
pub c: f32,
pub h: f32,
}
impl Oklch {
pub const fn new(l: f32, c: f32, h: f32) -> Self {
Self { l, c, h }
}
pub const fn grey(l: f32) -> Self {
Self { l, c: 0.0, h: 0.0 }
}
pub fn lighten(self, dl: f32) -> Self {
Self { l: (self.l + dl).clamp(0.0, 1.0), ..self }
}
pub fn with_chroma_scale(self, k: f32) -> Self {
Self { c: (self.c * k).max(0.0), ..self }
}
pub fn with_l(self, l: f32) -> Self {
Self { l: l.clamp(0.0, 1.0), ..self }
}
pub fn to_color32(self) -> Color32 {
let (r, g, b) = self.to_srgb8();
Color32::from_rgb(r, g, b)
}
pub fn to_color32_alpha(self, a: u8) -> Color32 {
let (r, g, b) = self.to_srgb8();
Color32::from_rgba_unmultiplied(r, g, b, a)
}
pub fn to_srgb8(self) -> (u8, u8, u8) {
let (lr, lg, lb) = self.to_linear_srgb();
(encode(lr), encode(lg), encode(lb))
}
pub fn to_linear_srgb(self) -> (f32, f32, f32) {
let hr = self.h.to_radians();
let a = self.c * hr.cos();
let b = self.c * hr.sin();
let l = self.l;
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let l3 = l_ * l_ * l_;
let m3 = m_ * m_ * m_;
let s3 = s_ * s_ * s_;
let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3;
let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3;
let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
}
}
fn encode(c: f32) -> u8 {
let c = c.clamp(0.0, 1.0);
let v = if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
(v * 255.0).round().clamp(0.0, 255.0) as u8
}
fn srgb8_to_linear(c: u8) -> f32 {
let c = c as f32 / 255.0;
if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
pub fn relative_luminance(c: Color32) -> f32 {
let r = srgb8_to_linear(c.r());
let g = srgb8_to_linear(c.g());
let b = srgb8_to_linear(c.b());
0.2126 * r + 0.7152 * g + 0.0722 * b
}
pub fn contrast_ratio(a: Color32, b: Color32) -> f32 {
let la = relative_luminance(a);
let lb = relative_luminance(b);
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn white_and_black_round_trip_to_extremes() {
let white = Oklch::grey(1.0).to_color32();
let black = Oklch::grey(0.0).to_color32();
assert_eq!(white, Color32::from_rgb(255, 255, 255));
assert_eq!(black, Color32::from_rgb(0, 0, 0));
}
#[test]
fn contrast_black_on_white_is_21() {
let r = contrast_ratio(Color32::BLACK, Color32::WHITE);
assert!((r - 21.0).abs() < 0.1, "black/white contrast ~21, got {r}");
}
#[test]
fn contrast_is_symmetric_and_self_is_one() {
let a = Oklch::new(0.6, 0.1, 250.0).to_color32();
let b = Oklch::new(0.2, 0.05, 30.0).to_color32();
assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 1e-4);
assert!((contrast_ratio(a, a) - 1.0).abs() < 1e-4);
}
#[test]
fn lighten_increases_luminance() {
let base = Oklch::new(0.4, 0.08, 250.0);
let lighter = base.lighten(0.2);
assert!(relative_luminance(lighter.to_color32()) > relative_luminance(base.to_color32()));
}
#[test]
fn a_known_blue_lands_in_blue_octant() {
let (r, g, b) = Oklch::new(0.6, 0.15, 255.0).to_srgb8();
assert!(b > r && b > g, "expected blue-dominant, got ({r},{g},{b})");
}
}