#![cfg_attr(not(test), allow(dead_code))]
use ratatui::style::Color;
pub(crate) fn color_to_srgb(c: Color) -> Option<(u8, u8, u8)> {
match c {
Color::Rgb(r, g, b) => Some((r, g, b)),
_ => None,
}
}
pub(crate) fn relative_luminance((r, g, b): (u8, u8, u8)) -> f64 {
fn channel(c: u8) -> f64 {
let s = f64::from(c) / 255.0;
if s <= 0.03928 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
pub(crate) fn contrast_ratio(fg: Color, bg: Color) -> Option<f64> {
let l1 = relative_luminance(color_to_srgb(fg)?);
let l2 = relative_luminance(color_to_srgb(bg)?);
let (light, dark) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
Some((light + 0.05) / (dark + 0.05))
}
pub(crate) fn luminance_delta(a: Color, b: Color) -> Option<f64> {
let la = relative_luminance(color_to_srgb(a)?);
let lb = relative_luminance(color_to_srgb(b)?);
Some((la - lb).abs() * 100.0)
}
pub trait ColorOps {
fn darken(self, factor: f64) -> Self;
fn lighten(self, factor: f64) -> Self;
fn is_light(&self) -> bool;
}
impl ColorOps for Color {
fn darken(self, factor: f64) -> Self {
let Some((r, g, b)) = color_to_srgb(self) else {
return self;
};
let f = factor.clamp(0.0, 1.0);
Color::Rgb(blend(r, 0, f), blend(g, 0, f), blend(b, 0, f))
}
fn lighten(self, factor: f64) -> Self {
let Some((r, g, b)) = color_to_srgb(self) else {
return self;
};
let f = factor.clamp(0.0, 1.0);
Color::Rgb(blend(r, 255, f), blend(g, 255, f), blend(b, 255, f))
}
fn is_light(&self) -> bool {
color_to_srgb(*self)
.map(|rgb| relative_luminance(rgb) > 0.5)
.unwrap_or(false)
}
}
fn blend(from: u8, to: u8, factor: f64) -> u8 {
let from = f64::from(from);
let to = f64::from(to);
(from + (to - from) * factor).round().clamp(0.0, 255.0) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contrast_ratio_matches_known_values() {
let r = contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0)).unwrap();
assert!((r - 21.0).abs() < 0.01, "white/black: {r:.4}");
let r = contrast_ratio(Color::Rgb(128, 128, 128), Color::Rgb(128, 128, 128)).unwrap();
assert!((r - 1.0).abs() < 0.01, "self/self: {r:.4}");
assert_eq!(contrast_ratio(Color::Cyan, Color::Black), None);
}
#[test]
fn darken_endpoints_and_identity() {
let c = Color::Rgb(100, 150, 200);
assert_eq!(c.darken(0.0), c, "factor=0 is identity");
assert_eq!(c.darken(1.0), Color::Rgb(0, 0, 0), "factor=1 is black");
assert_eq!(c.darken(-0.5), c, "negative factor clamps to 0");
assert_eq!(c.darken(1.5), Color::Rgb(0, 0, 0), "factor>1 clamps to 1");
assert_eq!(Color::Cyan.darken(0.5), Color::Cyan);
}
#[test]
fn lighten_endpoints_and_identity() {
let c = Color::Rgb(100, 150, 200);
assert_eq!(c.lighten(0.0), c, "factor=0 is identity");
assert_eq!(
c.lighten(1.0),
Color::Rgb(255, 255, 255),
"factor=1 is white"
);
assert_eq!(Color::Yellow.lighten(0.5), Color::Yellow);
}
#[test]
fn darken_50_percent_is_midway() {
let c = Color::Rgb(200, 100, 50);
let d = c.darken(0.5);
let Color::Rgb(r, g, b) = d else {
panic!("expected RGB")
};
assert!((i16::from(r) - 100).abs() <= 1, "r={r}");
assert!((i16::from(g) - 50).abs() <= 1, "g={g}");
assert!((i16::from(b) - 25).abs() <= 1, "b={b}");
}
#[test]
fn is_light_classifies_correctly() {
assert!(Color::Rgb(255, 255, 255).is_light(), "white is light");
assert!(!Color::Rgb(0, 0, 0).is_light(), "black is dark");
assert!(
Color::Rgb(253, 246, 227).is_light(),
"solarized base3 is light"
);
assert!(
!Color::Rgb(0, 43, 54).is_light(),
"solarized base03 is dark"
);
assert!(!Color::Cyan.is_light());
}
#[test]
fn luminance_delta_zero_for_same_color() {
let c = Color::Rgb(128, 128, 128);
assert_eq!(luminance_delta(c, c), Some(0.0));
}
}