use std::collections::BTreeMap;
use std::f32::consts::TAU;
use crate::color::Rgb;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Paint {
Solid(Rgb),
Pulse(Rgb, Rgb),
}
impl Paint {
#[must_use]
pub fn at(self, phase: f32) -> Rgb {
match self {
Self::Solid(color) => color,
Self::Pulse(from, to) => {
let t = (1.0 - (phase.rem_euclid(1.0) * TAU).cos()) / 2.0;
from.mix(to, t)
}
}
}
#[must_use]
pub fn is_animated(self) -> bool {
matches!(self, Self::Pulse(..))
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Expr {
Hex(Rgb),
Token(String),
Mix(Box<Expr>, Box<Expr>, f32),
Pulse(Box<Expr>, Box<Expr>),
}
impl Eq for Expr {}
impl Expr {
pub(crate) fn parse(text: &str) -> Result<Self, String> {
let mut parser = Parser { text, pos: 0, depth: 0 };
let expr = parser.expr()?;
parser.skip_space();
if parser.pos != text.len() {
return Err(format!("unexpected `{}` in colour `{text}`", &text[parser.pos..]));
}
Ok(expr)
}
pub(crate) fn resolve(&self, tokens: &BTreeMap<String, Rgb>) -> Result<Paint, String> {
match self {
Self::Pulse(a, b) => Ok(Paint::Pulse(a.solid(tokens)?, b.solid(tokens)?)),
other => other.solid(tokens).map(Paint::Solid),
}
}
pub(crate) fn solid(&self, tokens: &BTreeMap<String, Rgb>) -> Result<Rgb, String> {
self.solid_by(&|name| tokens.get(name).copied())
}
pub(crate) fn resolve_by(&self, lookup: &dyn Fn(&str) -> Option<Rgb>) -> Result<Paint, String> {
match self {
Self::Pulse(a, b) => Ok(Paint::Pulse(a.solid_by(lookup)?, b.solid_by(lookup)?)),
other => other.solid_by(lookup).map(Paint::Solid),
}
}
fn solid_by(&self, lookup: &dyn Fn(&str) -> Option<Rgb>) -> Result<Rgb, String> {
match self {
Self::Hex(color) => Ok(*color),
Self::Token(name) => lookup(name).ok_or_else(|| format!("unknown colour token `${name}`")),
Self::Mix(a, b, percent) => Ok(b.solid_by(lookup)?.mix(a.solid_by(lookup)?, percent / 100.0)),
Self::Pulse(..) => Err("pulse() can only be used as a whole value, not inside another colour".to_owned()),
}
}
pub(crate) fn nested_pulse(&self) -> Option<String> {
let inner = match self {
Self::Pulse(a, b) | Self::Mix(a, b, _) => [a, b],
Self::Hex(_) | Self::Token(_) => return None,
};
if inner.iter().any(|expr| expr.contains_pulse()) {
Some("pulse() can only be used as a whole value, not inside another colour".to_owned())
} else {
None
}
}
fn contains_pulse(&self) -> bool {
match self {
Self::Pulse(..) => true,
Self::Mix(a, b, _) => a.contains_pulse() || b.contains_pulse(),
Self::Hex(_) | Self::Token(_) => false,
}
}
pub(crate) fn tokens(&self) -> Vec<&str> {
match self {
Self::Hex(_) => Vec::new(),
Self::Token(name) => vec![name.as_str()],
Self::Mix(a, b, _) | Self::Pulse(a, b) => {
let mut names = a.tokens();
names.extend(b.tokens());
names
}
}
}
}
const MAX_NESTING: usize = 16;
struct Parser<'a> {
text: &'a str,
pos: usize,
depth: usize,
}
impl Parser<'_> {
fn rest(&self) -> &str {
&self.text[self.pos..]
}
fn skip_space(&mut self) {
let trimmed = self.rest().trim_start();
self.pos = self.text.len() - trimmed.len();
}
fn eat(&mut self, literal: &str) -> bool {
self.skip_space();
if self.rest().starts_with(literal) {
self.pos += literal.len();
true
} else {
false
}
}
fn expect(&mut self, literal: &str) -> Result<(), String> {
if self.eat(literal) { Ok(()) } else { Err(format!("expected `{literal}` in colour `{}`", self.text)) }
}
fn take_while(&mut self, keep: impl Fn(char) -> bool) -> &str {
let start = self.pos;
let len = self.rest().find(|c: char| !keep(c)).unwrap_or(self.rest().len());
self.pos += len;
&self.text[start..self.pos]
}
fn nest(&mut self) -> Result<(), String> {
self.depth += 1;
if self.depth > MAX_NESTING {
return Err(format!("colour `{}` nests more than {MAX_NESTING} levels of mix() or pulse()", self.text));
}
Ok(())
}
fn expr(&mut self) -> Result<Expr, String> {
if self.eat("$") {
let name = self.take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if name.is_empty() {
return Err(format!("missing token name after `$` in colour `{}`", self.text));
}
return Ok(Expr::Token(name.to_owned()));
}
if self.eat("#") {
let digits = self.take_while(|c| c.is_ascii_hexdigit()).to_owned();
return Rgb::parse_hex(&format!("#{digits}"))
.map(Expr::Hex)
.ok_or_else(|| format!("`#{digits}` is not a valid colour; use #RRGGBB or #RGB"));
}
if self.eat("mix(") {
self.nest()?;
let a = self.expr()?;
self.expect(",")?;
let b = self.expr()?;
self.expect(",")?;
self.skip_space();
let number = self.take_while(|c| c.is_ascii_digit() || c == '.').to_owned();
let percent: f32 =
number.parse().map_err(|_| format!("mix() needs a percentage like 30% in colour `{}`", self.text))?;
if !(0.0..=100.0).contains(&percent) {
return Err(format!("mix() percentage must be between 0% and 100%, got {number}%"));
}
self.expect("%")?;
self.expect(")")?;
self.depth -= 1;
return Ok(Expr::Mix(Box::new(a), Box::new(b), percent));
}
if self.eat("pulse(") {
self.nest()?;
let a = self.expr()?;
self.expect(",")?;
let b = self.expr()?;
self.expect(")")?;
self.depth -= 1;
return Ok(Expr::Pulse(Box::new(a), Box::new(b)));
}
Err(format!("`{}` is not a colour; use $token, #RRGGBB, mix(a, b, N%) or pulse(a, b)", self.text))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tokens() -> BTreeMap<String, Rgb> {
BTreeMap::from([("accent".to_owned(), Rgb::new(255, 255, 255)), ("surface".to_owned(), Rgb::new(0, 0, 0))])
}
#[test]
fn parses_every_form() {
assert_eq!(Expr::parse("#fff"), Ok(Expr::Hex(Rgb::new(255, 255, 255))));
assert_eq!(Expr::parse(" $accent-2 "), Ok(Expr::Token("accent-2".to_owned())));
let mix = Expr::parse("mix($accent, $surface, 34%)").expect("valid mix");
assert_eq!(mix.tokens(), vec!["accent", "surface"]);
assert!(matches!(Expr::parse("pulse($accent, #000)"), Ok(Expr::Pulse(..))));
}
#[test]
fn explains_bad_input() {
assert_eq!(Expr::parse("#38BDZ8").err().as_deref(), Some("`#38BD` is not a valid colour; use #RRGGBB or #RGB"));
assert!(Expr::parse("mix($a, $b)").is_err());
assert!(Expr::parse("mix($a, $b, 140%)").is_err());
assert!(Expr::parse("red").is_err());
assert!(Expr::parse("$accent extra").is_err());
}
#[test]
fn deep_nesting_is_an_error_not_a_stack_overflow() {
let nested = |levels: usize| format!("{}$accent{}", "mix(".repeat(levels), ", $surface, 50%)".repeat(levels));
assert!(Expr::parse(&nested(MAX_NESTING)).is_ok());
assert!(Expr::parse(&nested(MAX_NESTING + 1)).is_err_and(|message| message.contains("nests more than")));
assert!(Expr::parse(&nested(200_000)).is_err());
}
#[test]
fn resolves_mix_as_share_of_first_colour() {
let paint = Expr::parse("mix($accent, $surface, 25%)").and_then(|e| e.resolve(&tokens())).expect("resolves");
assert_eq!(paint, Paint::Solid(Rgb::new(64, 64, 64)));
}
#[test]
fn pulse_only_at_top_level() {
let nested = Expr::parse("mix(pulse($accent, $surface), $surface, 50%)").expect("parses");
assert!(nested.resolve(&tokens()).is_err());
let unknown = Expr::parse("$missing").expect("parses");
assert_eq!(unknown.resolve(&tokens()).err().as_deref(), Some("unknown colour token `$missing`"));
}
#[test]
fn pulse_breathes_between_ends() {
let paint = Paint::Pulse(Rgb::new(0, 0, 0), Rgb::new(200, 200, 200));
assert_eq!(paint.at(0.0), Rgb::new(0, 0, 0));
assert_eq!(paint.at(0.5), Rgb::new(200, 200, 200));
assert_eq!(paint.at(1.0), Rgb::new(0, 0, 0));
assert!(paint.is_animated());
assert!(!Paint::Solid(Rgb::new(1, 2, 3)).is_animated());
}
}