use iced_widget::core::Color;
struct Oklab {
l: f32,
a: f32,
b: f32,
alpha: f32,
}
fn to_oklab(color: Color) -> Oklab {
let [r, g, b, alpha] = color.into_linear();
let l = 0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b;
let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
let s = 0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b;
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
Oklab {
l: 0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_,
a: 1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_,
b: 0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_,
alpha,
}
}
fn from_oklab(color: Oklab) -> Color {
let Oklab { l, a, b, alpha } = color;
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 (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
Color::from_linear_rgba(
(4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s).clamp(0.0, 1.0),
(-1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s).clamp(0.0, 1.0),
(-0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s).clamp(0.0, 1.0),
alpha,
)
}
pub(crate) fn lightness(color: Color) -> f32 {
to_oklab(color).l
}
pub(crate) fn shift(color: Color, delta: f32) -> Color {
let base = to_oklab(color);
from_oklab(Oklab {
l: (base.l + delta).clamp(0.0, 1.0),
..base
})
}
pub(crate) fn blend(from: Color, to: Color, t: f32) -> Color {
let (a, b) = (to_oklab(from), to_oklab(to));
from_oklab(Oklab {
l: a.l + (b.l - a.l) * t,
a: a.a + (b.a - a.a) * t,
b: a.b + (b.b - a.b) * t,
alpha: a.alpha + (b.alpha - a.alpha) * t,
})
}
pub(crate) fn separate(color: Color, reference: Color, min_delta: f32) -> Color {
let base = to_oklab(color);
let anchor = lightness(reference);
if (base.l - anchor).abs() >= min_delta {
return color;
}
let up = anchor + min_delta;
let down = anchor - min_delta;
let target = if base.l >= anchor {
if up <= 1.0 { up } else { down }
} else if down >= 0.0 {
down
} else {
up
};
from_oklab(Oklab {
l: target.clamp(0.0, 1.0),
..base
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oklab_round_trips() {
for c in [
Color::BLACK,
Color::WHITE,
Color::from_rgb(0.1, 0.2, 0.35),
Color::from_rgb(0.95, 0.3, 0.1),
] {
let back = from_oklab(to_oklab(c));
for (a, b) in [(c.r, back.r), (c.g, back.g), (c.b, back.b)] {
assert!((a - b).abs() < 1e-3, "{c:?} round-tripped to {back:?}");
}
}
}
#[test]
fn a_lightness_step_holds_hue_and_chroma() {
let navy = Color::from_rgb8(0x01, 0x16, 0x27);
let raised = shift(navy, 0.1);
let (before, after) = (to_oklab(navy), to_oklab(raised));
assert!(
(after.l - before.l - 0.1).abs() < 1e-3,
"lightness moved by 0.1"
);
assert!(
(after.a - before.a).abs() < 1e-3,
"hue/chroma axis a is held"
);
assert!(
(after.b - before.b).abs() < 1e-3,
"hue/chroma axis b is held"
);
let iced_step = iced_widget::core::theme::palette::lighten(navy, 0.1);
let iced_chroma = to_oklab(iced_step);
let chroma = |o: &Oklab| (o.a * o.a + o.b * o.b).sqrt();
assert!(
chroma(&iced_chroma) > chroma(&before) * 1.5,
"iced's step is expected to amplify chroma; this ladder must not"
);
}
#[test]
fn elevation_is_uniform_across_the_range() {
let near_black = Color::from_rgb8(0x08, 0x08, 0x08);
let near_white = Color::from_rgb8(0xef, 0xf1, 0xf5);
let dark_step = lightness(shift(near_black, 0.06)) - lightness(near_black);
let light_step = lightness(near_white) - lightness(shift(near_white, -0.06));
assert!((dark_step - 0.06).abs() < 1e-3);
assert!((light_step - 0.06).abs() < 1e-3);
}
#[test]
fn separate_lifts_an_accent_that_collides_with_its_canvas() {
let canvas = Color::from_rgb8(0x18, 0x16, 0x16);
let accent = Color::from_rgb8(0x22, 0x32, 0x49);
assert!(
(lightness(accent) - lightness(canvas)).abs() < 0.3,
"precondition"
);
let guarded = separate(accent, canvas, 0.32);
assert!((lightness(guarded) - lightness(canvas)).abs() >= 0.315);
}
#[test]
fn separate_leaves_a_contrasting_accent_alone() {
let canvas = Color::from_rgb8(0x1e, 0x1e, 0x2e);
let accent = Color::from_rgb8(0x89, 0xb4, 0xfa);
assert_eq!(separate(accent, canvas, 0.32), accent);
}
#[test]
fn separate_flips_when_the_leaning_direction_has_no_room() {
let canvas = Color::WHITE;
let accent = Color::from_rgb8(0xf0, 0xf0, 0xf0);
let guarded = separate(accent, canvas, 0.32);
assert!(lightness(guarded) <= lightness(canvas) - 0.315);
}
}