use crate::math::{clamp01, lerp};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Rgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl Rgba {
pub const BLACK: Self = Self::rgb8(0, 0, 0);
pub const WHITE: Self = Self::rgb8(255, 255, 255);
pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
pub const fn rgb8(r: u8, g: u8, b: u8) -> Self {
Self {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: 1.0,
}
}
pub const fn rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
Self {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: a as f32 / 255.0,
}
}
pub fn with_alpha(self, alpha: f32) -> Self {
Self {
r: sanitize_channel(self.r),
g: sanitize_channel(self.g),
b: sanitize_channel(self.b),
a: clamp01(alpha),
}
}
pub fn mix(self, other: Self, amount: f32) -> Self {
let amount = clamp01(amount);
let left = self.sanitized();
let right = other.sanitized();
Self {
r: lerp(left.r, right.r, amount),
g: lerp(left.g, right.g, amount),
b: lerp(left.b, right.b, amount),
a: lerp(left.a, right.a, amount),
}
}
pub fn sanitized(self) -> Self {
Self {
r: sanitize_channel(self.r),
g: sanitize_channel(self.g),
b: sanitize_channel(self.b),
a: sanitize_channel(self.a),
}
}
pub fn relative_luminance(self) -> f32 {
fn linear(channel: f32) -> f32 {
if channel <= 0.04045 {
channel / 12.92
} else {
((channel + 0.055) / 1.055).powf(2.4)
}
}
linear(sanitize_channel(self.r)) * 0.2126
+ linear(sanitize_channel(self.g)) * 0.7152
+ linear(sanitize_channel(self.b)) * 0.0722
}
pub fn contrast_ratio(self, other: Self) -> f32 {
let left = self.relative_luminance();
let right = other.relative_luminance();
let lighter = left.max(right);
let darker = left.min(right);
(lighter + 0.05) / (darker + 0.05)
}
pub fn readable_text_color(self) -> Self {
if self.contrast_ratio(Self::BLACK) >= self.contrast_ratio(Self::WHITE) {
Self::BLACK
} else {
Self::WHITE
}
}
pub fn to_array(self) -> [f32; 4] {
let color = self.sanitized();
[color.r, color.g, color.b, color.a]
}
}
fn sanitize_channel(channel: f32) -> f32 {
if channel.is_finite() {
channel.clamp(0.0, 1.0)
} else {
0.0
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Palette {
pub name: String,
pub background: Rgba,
pub panel: Rgba,
pub panel_hot: Rgba,
pub text: Rgba,
pub muted: Rgba,
pub accent: Rgba,
pub success: Rgba,
pub warning: Rgba,
pub error: Rgba,
}
impl Palette {
pub fn midnight() -> Self {
Self {
name: "midnight".to_string(),
background: Rgba::rgb8(3, 5, 14),
panel: Rgba::rgba8(8, 13, 28, 226),
panel_hot: Rgba::rgba8(19, 31, 62, 236),
text: Rgba::rgb8(225, 235, 255),
muted: Rgba::rgb8(119, 139, 168),
accent: Rgba::rgb8(122, 185, 255),
success: Rgba::rgb8(83, 225, 147),
warning: Rgba::rgb8(255, 190, 94),
error: Rgba::rgb8(255, 105, 105),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contrast_ratio_matches_black_white_reference() {
let ratio = Rgba::BLACK.contrast_ratio(Rgba::WHITE);
assert!((ratio - 21.0).abs() < 0.01);
}
#[test]
fn luminance_uses_srgb_transfer_for_mid_gray() {
let luminance = Rgba::rgb8(128, 128, 128).relative_luminance();
assert!((luminance - 0.21586).abs() < 0.0001);
}
#[test]
fn readable_text_color_picks_higher_contrast_endpoint() {
assert_eq!(Rgba::BLACK.readable_text_color(), Rgba::WHITE);
assert_eq!(Rgba::WHITE.readable_text_color(), Rgba::BLACK);
assert_eq!(Rgba::rgb8(255, 190, 94).readable_text_color(), Rgba::BLACK);
}
#[test]
fn luminance_clamps_non_finite_and_out_of_range_channels() {
let luminance = Rgba::new(f32::NAN, -1.0, 2.0, 1.0).relative_luminance();
assert!(luminance.is_finite());
assert!((luminance - 0.0722).abs() < 0.0001);
}
#[test]
fn alpha_and_mix_amounts_are_clamped_for_non_finite_inputs() {
assert_eq!(Rgba::WHITE.with_alpha(f32::NAN).a, 0.0);
assert_eq!(Rgba::WHITE.with_alpha(2.0).a, 1.0);
assert_eq!(Rgba::BLACK.mix(Rgba::WHITE, f32::NAN), Rgba::BLACK);
}
#[test]
fn renderer_facing_channels_are_finite_and_bounded() {
let malformed = Rgba::new(f32::NAN, f32::INFINITY, -1.0, 2.0);
assert_eq!(malformed.to_array(), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(malformed.with_alpha(0.5).to_array(), [0.0, 0.0, 0.0, 0.5]);
assert_eq!(
Rgba::BLACK.mix(malformed, 1.0).to_array(),
[0.0, 0.0, 0.0, 1.0]
);
}
}