argui-core 0.1.0

Geometry, color, identifiers and shared primitives for Argui
Documentation
use core::fmt;

#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Color([f32; 4]);

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ColorInterpolation {
    #[default]
    Oklab,
    LinearSrgb,
    Srgb,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParseColorError {
    MissingHash,
    InvalidLength,
    InvalidDigit,
}

impl fmt::Display for ParseColorError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingHash => formatter.write_str("a hexadecimal color must start with '#'"),
            Self::InvalidLength => {
                formatter.write_str("a hexadecimal color must contain 3, 4, 6, or 8 digits")
            }
            Self::InvalidDigit => {
                formatter.write_str("a hexadecimal color contains a non-hexadecimal digit")
            }
        }
    }
}

impl std::error::Error for ParseColorError {}

impl Color {
    pub const TRANSPARENT: Self = Self::linear_rgba(0.0, 0.0, 0.0, 0.0);
    pub const BLACK: Self = Self::linear_rgb(0.0, 0.0, 0.0);
    pub const WHITE: Self = Self::linear_rgb(1.0, 1.0, 1.0);

    #[must_use]
    pub fn srgb(red: f32, green: f32, blue: f32) -> Self {
        Self::srgba(red, green, blue, 1.0)
    }

    #[must_use]
    pub fn srgba(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
        Self::linear_rgba(
            srgb_to_linear(red),
            srgb_to_linear(green),
            srgb_to_linear(blue),
            alpha,
        )
    }

    #[must_use]
    pub const fn linear_rgb(red: f32, green: f32, blue: f32) -> Self {
        Self::linear_rgba(red, green, blue, 1.0)
    }

    #[must_use]
    pub const fn linear_rgba(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
        Self([red, green, blue, alpha])
    }

    #[must_use]
    pub fn from_srgb8(red: u8, green: u8, blue: u8) -> Self {
        Self::from_srgba8(red, green, blue, u8::MAX)
    }

    #[must_use]
    pub fn from_srgba8(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
        Self::srgba(
            f32::from(red) / 255.0,
            f32::from(green) / 255.0,
            f32::from(blue) / 255.0,
            f32::from(alpha) / 255.0,
        )
    }

    pub fn from_hex(value: &str) -> Result<Self, ParseColorError> {
        let digits = value
            .strip_prefix('#')
            .ok_or(ParseColorError::MissingHash)?;
        let channels = match digits.len() {
            3 => [
                repeated_nibble(digits, 0)?,
                repeated_nibble(digits, 1)?,
                repeated_nibble(digits, 2)?,
                u8::MAX,
            ],
            4 => [
                repeated_nibble(digits, 0)?,
                repeated_nibble(digits, 1)?,
                repeated_nibble(digits, 2)?,
                repeated_nibble(digits, 3)?,
            ],
            6 => [
                byte(digits, 0)?,
                byte(digits, 2)?,
                byte(digits, 4)?,
                u8::MAX,
            ],
            8 => [
                byte(digits, 0)?,
                byte(digits, 2)?,
                byte(digits, 4)?,
                byte(digits, 6)?,
            ],
            _ => return Err(ParseColorError::InvalidLength),
        };
        Ok(Self::from_srgba8(
            channels[0],
            channels[1],
            channels[2],
            channels[3],
        ))
    }

    #[must_use]
    pub const fn to_linear_rgba(self) -> [f32; 4] {
        self.0
    }

    #[must_use]
    pub fn to_srgba(self) -> [f32; 4] {
        let [red, green, blue, alpha] = self.0;
        [
            linear_to_srgb(red),
            linear_to_srgb(green),
            linear_to_srgb(blue),
            alpha,
        ]
    }

    #[must_use]
    pub fn to_srgba8(self) -> [u8; 4] {
        self.to_srgba().map(unit_to_byte)
    }

    #[must_use]
    pub const fn with_alpha(self, alpha: f32) -> Self {
        Self([self.0[0], self.0[1], self.0[2], alpha])
    }

    #[must_use]
    pub fn mix(self, target: Self, progress: f32, space: ColorInterpolation) -> Self {
        let alpha = lerp(self.0[3], target.0[3], progress);
        let left = coordinates(self, space);
        let right = coordinates(target, space);
        let mixed = if alpha.abs() <= f32::EPSILON {
            [0.0; 3]
        } else {
            [
                lerp(left[0] * self.0[3], right[0] * target.0[3], progress) / alpha,
                lerp(left[1] * self.0[3], right[1] * target.0[3], progress) / alpha,
                lerp(left[2] * self.0[3], right[2] * target.0[3], progress) / alpha,
            ]
        };
        from_coordinates(mixed, alpha, space)
    }

    #[must_use]
    pub fn to_interpolation_components(self, space: ColorInterpolation) -> [f32; 4] {
        let coordinates = coordinates(self, space);
        [coordinates[0], coordinates[1], coordinates[2], self.0[3]]
    }

    #[must_use]
    pub fn from_interpolation_components(components: [f32; 4], space: ColorInterpolation) -> Self {
        from_coordinates(
            [components[0], components[1], components[2]],
            components[3],
            space,
        )
    }

    #[must_use]
    pub fn relative_luminance(self) -> f32 {
        0.2126 * self.0[0] + 0.7152 * self.0[1] + 0.0722 * self.0[2]
    }

    #[must_use]
    pub fn contrast_ratio(self, other: Self) -> f32 {
        let lighter = self.relative_luminance().max(other.relative_luminance());
        let darker = self.relative_luminance().min(other.relative_luminance());
        (lighter + 0.05) / (darker + 0.05)
    }
}

fn coordinates(color: Color, space: ColorInterpolation) -> [f32; 3] {
    let [red, green, blue, _] = color.0;
    match space {
        ColorInterpolation::LinearSrgb => [red, green, blue],
        ColorInterpolation::Srgb => [
            linear_to_srgb(red),
            linear_to_srgb(green),
            linear_to_srgb(blue),
        ],
        ColorInterpolation::Oklab => linear_srgb_to_oklab([red, green, blue]),
    }
}

fn from_coordinates(value: [f32; 3], alpha: f32, space: ColorInterpolation) -> Color {
    let [red, green, blue] = match space {
        ColorInterpolation::LinearSrgb => value,
        ColorInterpolation::Srgb => value.map(srgb_to_linear),
        ColorInterpolation::Oklab => oklab_to_linear_srgb(value),
    };
    Color::linear_rgba(red, green, blue, alpha)
}

fn linear_srgb_to_oklab([red, green, blue]: [f32; 3]) -> [f32; 3] {
    let l = 0.412_221_46 * red + 0.536_332_55 * green + 0.051_445_995 * blue;
    let m = 0.211_903_5 * red + 0.680_699_5 * green + 0.107_396_96 * blue;
    let s = 0.088_302_46 * red + 0.281_718_85 * green + 0.629_978_7 * blue;
    let [l, m, s] = [l.cbrt(), m.cbrt(), s.cbrt()];
    [
        0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
        1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
        0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
    ]
}

fn oklab_to_linear_srgb([lightness, a, b]: [f32; 3]) -> [f32; 3] {
    let l = (lightness + 0.396_337_78 * a + 0.215_803_76 * b).powi(3);
    let m = (lightness - 0.105_561_346 * a - 0.063_854_17 * b).powi(3);
    let s = (lightness - 0.089_484_18 * a - 1.291_485_5 * b).powi(3);
    [
        4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
        -1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
        -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
    ]
}

fn srgb_to_linear(value: f32) -> f32 {
    let sign = value.signum();
    let magnitude = value.abs();
    sign * if magnitude <= 0.04045 {
        magnitude / 12.92
    } else {
        ((magnitude + 0.055) / 1.055).powf(2.4)
    }
}

fn linear_to_srgb(value: f32) -> f32 {
    let sign = value.signum();
    let magnitude = value.abs();
    sign * if magnitude <= 0.003_130_8 {
        magnitude * 12.92
    } else {
        1.055 * magnitude.powf(1.0 / 2.4) - 0.055
    }
}

fn lerp(left: f32, right: f32, progress: f32) -> f32 {
    left + (right - left) * progress
}

fn unit_to_byte(value: f32) -> u8 {
    (value.clamp(0.0, 1.0) * 255.0).round() as u8
}

fn repeated_nibble(value: &str, offset: usize) -> Result<u8, ParseColorError> {
    let nibble = digit(value.as_bytes()[offset])?;
    Ok(nibble * 17)
}

fn byte(value: &str, offset: usize) -> Result<u8, ParseColorError> {
    Ok(digit(value.as_bytes()[offset])? * 16 + digit(value.as_bytes()[offset + 1])?)
}

fn digit(value: u8) -> Result<u8, ParseColorError> {
    match value {
        b'0'..=b'9' => Ok(value - b'0'),
        b'a'..=b'f' => Ok(value - b'a' + 10),
        b'A'..=b'F' => Ok(value - b'A' + 10),
        _ => Err(ParseColorError::InvalidDigit),
    }
}