colors.rs

12.0 kB · rust · 405 lines

1use super::errors::{value_error, MrlyError, Result};2use super::state;3use serde::{Deserialize, Deserializer, Serialize, Serializer};45/// An rgba color with byte channels.6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]7pub struct Color {8    /// The red channel.9    pub r: u8,10    /// The green channel.11    pub g: u8,12    /// The blue channel.13    pub b: u8,14    /// The alpha channel, 255 for opaque.15    pub a: u8,16}1718/// The fully transparent color.19pub const ALPHA: Color = Color::rgba(0, 0, 0, 0);2021pub use crate::palette::*;2223// SHADES2425/// Returns a hue one shade lighter, itself, and one shade darker.26pub fn shades(hue: Color) -> [Color; 3] {27    let lerp = |to: Color, t: f64| {28        let step = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;29        Color::rgb(step(hue.r, to.r), step(hue.g, to.g), step(hue.b, to.b))30    };31    [lerp(WHITE, 0.5), hue, lerp(BLACK, 0.4)]32}3334// THEME3536/// One theme: the surfaces of a dark or a light ground and the thirteen inks, the same on both.37#[derive(Clone, Copy, Debug, PartialEq, Eq)]38pub struct Theme {39    /// The ground every figure is painted on.40    pub ground: Color,41    /// The page background, one step off the ground.42    pub bg: Color,43    /// The raised panel.44    pub panel: Color,45    /// The sunken well.46    pub deep: Color,47    /// The hairline between things.48    pub line: Color,49    /// The foreground, the strongest tone.50    pub fg: Color,51    /// The dimmed foreground, for anything secondary.52    pub dim: Color,53    /// The interactive accent.54    pub accent: Color,55    /// The tone written on the accent.56    pub on_accent: Color,57    /// The red ink.58    pub red: Color,59    /// The orange ink.60    pub orange: Color,61    /// The yellow ink.62    pub yellow: Color,63    /// The green ink.64    pub green: Color,65    /// The mint ink.66    pub mint: Color,67    /// The teal ink.68    pub teal: Color,69    /// The cyan ink.70    pub cyan: Color,71    /// The blue ink.72    pub blue: Color,73    /// The indigo ink.74    pub indigo: Color,75    /// The purple ink.76    pub purple: Color,77    /// The pink ink.78    pub pink: Color,79    /// The brown ink.80    pub brown: Color,81    /// The gray ink.82    pub gray: Color,83}8485impl Theme {86    /// The thirteen inks in name order.87    pub fn hues(&self) -> [Color; 13] {88        [89            self.red,90            self.orange,91            self.yellow,92            self.green,93            self.mint,94            self.teal,95            self.cyan,96            self.blue,97            self.indigo,98            self.purple,99            self.pink,100            self.brown,101            self.gray,102        ]103    }104    /// The six inks a figure cycles through: blue, orange, yellow, green, pink, indigo.105    pub fn inks(&self) -> [Color; 6] {106        [107            self.blue,108            self.orange,109            self.yellow,110            self.green,111            self.pink,112            self.indigo,113        ]114    }115}116117const INKS: Theme = Theme {118    ground: BLACK,119    bg: BLACK,120    panel: BLACK,121    deep: BLACK,122    line: BLACK,123    fg: WHITE,124    dim: GRAY,125    accent: BLUE,126    on_accent: WHITE,127    red: RED,128    orange: ORANGE,129    yellow: YELLOW,130    green: GREEN,131    mint: MINT,132    teal: TEAL,133    cyan: CYAN,134    blue: BLUE,135    indigo: INDIGO,136    purple: PURPLE,137    pink: PINK,138    brown: BROWN,139    gray: GRAY,140};141142/// The dark theme.143pub const DARK: Theme = Theme {144    ground: BLACK,145    bg: Color::rgb(7, 7, 7),146    panel: Color::rgb(17, 17, 18),147    deep: BLACK,148    line: Color::rgb(31, 31, 32),149    fg: WHITE,150    dim: GRAY,151    ..INKS152};153154/// The light theme.155pub const LIGHT: Theme = Theme {156    ground: WHITE,157    bg: Color::rgb(248, 248, 249),158    panel: Color::rgb(241, 241, 242),159    deep: Color::rgb(232, 232, 233),160    line: Color::rgb(221, 221, 223),161    fg: BLACK,162    dim: Color::rgb(85, 85, 88),163    ..INKS164};165166/// Returns the palette color a name spells, or an error for a stranger.167pub fn named(name: &str) -> Result<Color> {168    match NAMES.iter().position(|&n| n == name) {169        Some(i) => Ok(PALETTE[i]),170        None => value_error(format!("unknown color name {name:?}.")),171    }172}173174/// Returns the ground rgba of the dark or the light theme.175pub fn board(dark: bool) -> [u8; 4] {176    let c = if dark { DARK.ground } else { LIGHT.ground };177    [c.r, c.g, c.b, c.a]178}179180/// Returns the foreground rgba of the dark or the light theme.181pub fn ink(dark: bool) -> [u8; 4] {182    let c = if dark { DARK.fg } else { LIGHT.fg };183    [c.r, c.g, c.b, c.a]184}185186/// Formats a raw rgba as a hex string.187pub fn hex(c: [u8; 4]) -> String {188    Color::rgba(c[0], c[1], c[2], c[3]).to_hex()189}190191/// Parses a hex string to raw rgba, falling back to opaque black.192pub fn hex_of(hex: &str) -> [u8; 4] {193    match Color::from_hex(hex) {194        Ok(c) => [c.r, c.g, c.b, c.a],195        Err(_) => [0, 0, 0, 255],196    }197}198199impl Serialize for Color {200    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {201        serializer.serialize_str(&self.to_hex())202    }203}204205impl<'de> Deserialize<'de> for Color {206    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Color, D::Error> {207        Color::from_hex(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom)208    }209}210211impl Color {212    /// Builds an opaque color.213    pub const fn rgb(r: u8, g: u8, b: u8) -> Color {214        Color { r, g, b, a: 255 }215    }216    /// Builds a color with an explicit alpha.217    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {218        Color { r, g, b, a }219    }220    /// Formats the color as lowercase hex, appending the alpha pair only when not opaque.221    pub fn to_hex(&self) -> String {222        if self.a == 255 {223            format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)224        } else {225            format!("#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a)226        }227    }228    /// Parses a #RRGGBB or #RRGGBBAA code, hash optional, or an error for anything else.229    ///230    /// ```231    /// assert_eq!(mrlycore::Color::from_hex("#ff3d40").unwrap(), mrlycore::colors::RED);232    /// ```233    pub fn from_hex(hex: &str) -> Result<Color> {234        let code = hex.trim_start_matches('#');235        let byte = |i: usize| -> Result<u8> {236            let pair = code237                .get(i..i + 2)238                .ok_or_else(|| MrlyError::Value(format!("invalid hex code {hex:?}.")))?;239            u8::from_str_radix(pair, 16)240                .map_err(|_| MrlyError::Value(format!("invalid hex code {hex:?}.")))241        };242        if !code.is_ascii() {243            return value_error("Hex code must be in format #RRGGBB or #RRGGBBAA");244        }245        match code.len() {246            6 => Ok(Color::rgb(byte(0)?, byte(2)?, byte(4)?)),247            8 => Ok(Color::rgba(byte(0)?, byte(2)?, byte(4)?, byte(6)?)),248            _ => value_error("Hex code must be in format #RRGGBB or #RRGGBBAA"),249        }250    }251    /// Formats the color as a css rgb or rgba call.252    pub fn css(&self) -> String {253        if self.a == 255 {254            format!("rgb({},{},{})", self.r, self.g, self.b)255        } else {256            format!("rgba({},{},{},{})", self.r, self.g, self.b, self.a)257        }258    }259    /// Returns the color with its alpha set to level.260    pub fn alpha(&self, level: u8) -> Color {261        Color::rgba(self.r, self.g, self.b, level)262    }263    /// Returns the color with every channel flipped and the alpha kept.264    pub fn invert(&self) -> Color {265        Color::rgba(255 - self.r, 255 - self.g, 255 - self.b, self.a)266    }267    /// Returns the color scaled toward black below level 50 and toward white above, or an error past 100.268    pub fn lightness(&self, level: u8) -> Result<Color> {269        if level > 100 {270            return value_error(format!("Level must be between 0 and 100, got {level}"));271        }272        let scale = |v: u8| -> u8 {273            if level == 50 {274                v275            } else if level < 50 {276                (v as f64 * level as f64 / 50.0) as u8277            } else {278                (v as f64 + (255.0 - v as f64) * (level as f64 - 50.0) / 50.0) as u8279            }280        };281        Ok(Color::rgba(282            scale(self.r),283            scale(self.g),284            scale(self.b),285            self.a,286        ))287    }288    /// Draws a color from the shared rng, opaque unless alpha is asked for.289    pub fn random(alpha: bool) -> Color {290        Color::rgba(291            state::randint(0, 255) as u8,292            state::randint(0, 255) as u8,293            state::randint(0, 255) as u8,294            if alpha {295                state::randint(0, 255) as u8296            } else {297                255298            },299        )300    }301}302303/// Blends two colors linearly by ratio, or an error outside the unit interval.304pub fn mix(color_1: Color, color_2: Color, ratio: f64) -> Result<Color> {305    if !(0.0..=1.0).contains(&ratio) {306        return value_error(format!("Ratio must be between 0.0 and 1.0, got {ratio}"));307    }308    let lerp = |a: u8, b: u8| -> u8 { (a as f64 + (b as f64 - a as f64) * ratio) as u8 };309    Ok(Color::rgba(310        lerp(color_1.r, color_2.r),311        lerp(color_1.g, color_2.g),312        lerp(color_1.b, color_2.b),313        lerp(color_1.a, color_2.a),314    ))315}316317/// Builds a gradient of steps colors sweeping evenly through the given stops.318pub fn gradient(colors: &[Color], steps: usize) -> Result<Vec<Color>> {319    if colors.is_empty() {320        return value_error("Cannot create a gradient from an empty list of colors.");321    }322    if steps < 1 {323        return value_error("Steps must be at least 1.");324    }325    if steps == 1 {326        return Ok(vec![colors[0]]);327    }328    if colors.len() == 1 {329        return Ok(vec![colors[0]; steps]);330    }331    let segments = colors.len() - 1;332    let mut result = Vec::with_capacity(steps);333    for i in 0..steps {334        let pos = i as f64 / (steps - 1) as f64;335        let mut seg = (pos * segments as f64) as usize;336        if seg >= segments {337            seg = segments - 1;338        }339        let ratio = pos * segments as f64 - seg as f64;340        result.push(mix(colors[seg], colors[seg + 1], ratio)?);341    }342    Ok(result)343}344345#[cfg(test)]346mod tests {347    #[test]348    fn from_hex_errors_on_non_ascii_instead_of_panicking() {349        use super::*;350        for bad in [351            "a\u{e9}bcd",352            "\u{e9}\u{e9}\u{e9}",353            "#a\u{e9}bcd",354            "\u{1f600}\u{1f600}",355        ] {356            assert!(Color::from_hex(bad).is_err(), "{bad:?} must be an error");357        }358        assert_eq!(Color::from_hex("#ff0000").unwrap(), Color::rgb(255, 0, 0));359    }360361    use super::*;362    #[test]363    fn named_palette() {364        assert_eq!(named("black").unwrap(), BLACK);365        assert_eq!(named("white").unwrap(), WHITE);366        assert_eq!(named("red").unwrap(), RED);367        assert_eq!(named("blue").unwrap(), BLUE);368        assert!(named("chartreuse").is_err());369        assert_eq!(NAMES.len(), PALETTE.len());370    }371    #[test]372    fn hex_round_trip() {373        for color in PALETTE {374            assert_eq!(Color::from_hex(&color.to_hex()).unwrap(), color);375        }376        assert_eq!(RED.to_hex(), "#ff3d40");377        assert_eq!(ALPHA.to_hex(), "#00000000");378    }379    #[test]380    fn gradient_endpoints() {381        let g = gradient(&[BLACK, WHITE], 5).unwrap();382        assert_eq!(g.len(), 5);383        assert_eq!(g[0], BLACK);384        assert_eq!(g[4], WHITE);385        assert_eq!(g[2], Color::rgb(127, 127, 127));386    }387    #[test]388    fn raw_hex_helpers_never_fail() {389        assert_eq!(hex([255, 61, 64, 255]), "#ff3d40");390        assert_eq!(hex([0, 0, 0, 0]), "#00000000");391        assert_eq!(hex_of("#ff3d40"), [255, 61, 64, 255]);392        assert_eq!(hex_of("#00000000"), [0, 0, 0, 0]);393        assert_eq!(hex_of("junk"), [0, 0, 0, 255]);394        assert_eq!(ink(true), [255, 255, 255, 255]);395        assert_eq!(board(false), [255, 255, 255, 255]);396    }397    #[test]398    fn mix_and_lightness() {399        assert_eq!(mix(BLACK, WHITE, 0.5).unwrap(), Color::rgb(127, 127, 127));400        assert!(mix(BLACK, WHITE, 1.5).is_err());401        assert_eq!(BLACK.lightness(100).unwrap(), WHITE);402        assert_eq!(WHITE.lightness(0).unwrap(), BLACK);403        assert_eq!(RED.lightness(50).unwrap(), RED);404    }405}