rae 0.1.13

Renderer-neutral Rust design catalog for desktop widgets, layouts, sanitized output, and GLSL shader primitives.
Documentation
use crate::math::{clamp01, lerp};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

/// RGBA color with sRGB-style channels conventionally stored in the `0.0..=1.0` range.
#[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),
        }
    }

    /// Returns WCAG relative luminance, clamping non-finite or out-of-range color channels.
    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
    }

    /// Returns the WCAG contrast ratio between two colors.
    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)
    }

    /// Chooses black or white text, whichever has higher contrast on this color.
    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]
        );
    }
}