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}