1use std::collections::BTreeMap;
17use std::f32::consts::TAU;
18
19use crate::color::Rgb;
20
21#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum Paint {
24 Solid(Rgb),
26 Pulse(Rgb, Rgb),
28}
29
30impl Paint {
31 #[must_use]
35 pub fn at(self, phase: f32) -> Rgb {
36 match self {
37 Self::Solid(color) => color,
38 Self::Pulse(from, to) => {
39 let t = (1.0 - (phase.rem_euclid(1.0) * TAU).cos()) / 2.0;
40 from.mix(to, t)
41 }
42 }
43 }
44
45 #[must_use]
47 pub fn is_animated(self) -> bool {
48 matches!(self, Self::Pulse(..))
49 }
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub(crate) enum Expr {
55 Hex(Rgb),
56 Token(String),
57 Mix(Box<Expr>, Box<Expr>, f32),
58 Pulse(Box<Expr>, Box<Expr>),
59}
60
61impl Eq for Expr {}
63
64impl Expr {
65 pub(crate) fn parse(text: &str) -> Result<Self, String> {
66 let mut parser = Parser { text, pos: 0, depth: 0 };
67 let expr = parser.expr()?;
68 parser.skip_space();
69 if parser.pos != text.len() {
70 return Err(format!("unexpected `{}` in colour `{text}`", &text[parser.pos..]));
71 }
72 Ok(expr)
73 }
74
75 pub(crate) fn resolve(&self, tokens: &BTreeMap<String, Rgb>) -> Result<Paint, String> {
77 match self {
78 Self::Pulse(a, b) => Ok(Paint::Pulse(a.solid(tokens)?, b.solid(tokens)?)),
79 other => other.solid(tokens).map(Paint::Solid),
80 }
81 }
82
83 pub(crate) fn solid(&self, tokens: &BTreeMap<String, Rgb>) -> Result<Rgb, String> {
85 self.solid_by(&|name| tokens.get(name).copied())
86 }
87
88 pub(crate) fn resolve_by(&self, lookup: &dyn Fn(&str) -> Option<Rgb>) -> Result<Paint, String> {
90 match self {
91 Self::Pulse(a, b) => Ok(Paint::Pulse(a.solid_by(lookup)?, b.solid_by(lookup)?)),
92 other => other.solid_by(lookup).map(Paint::Solid),
93 }
94 }
95
96 fn solid_by(&self, lookup: &dyn Fn(&str) -> Option<Rgb>) -> Result<Rgb, String> {
98 match self {
99 Self::Hex(color) => Ok(*color),
100 Self::Token(name) => lookup(name).ok_or_else(|| format!("unknown colour token `${name}`")),
101 Self::Mix(a, b, percent) => Ok(b.solid_by(lookup)?.mix(a.solid_by(lookup)?, percent / 100.0)),
102 Self::Pulse(..) => Err("pulse() can only be used as a whole value, not inside another colour".to_owned()),
103 }
104 }
105
106 pub(crate) fn nested_pulse(&self) -> Option<String> {
108 let inner = match self {
109 Self::Pulse(a, b) | Self::Mix(a, b, _) => [a, b],
110 Self::Hex(_) | Self::Token(_) => return None,
111 };
112 if inner.iter().any(|expr| expr.contains_pulse()) {
113 Some("pulse() can only be used as a whole value, not inside another colour".to_owned())
114 } else {
115 None
116 }
117 }
118
119 fn contains_pulse(&self) -> bool {
120 match self {
121 Self::Pulse(..) => true,
122 Self::Mix(a, b, _) => a.contains_pulse() || b.contains_pulse(),
123 Self::Hex(_) | Self::Token(_) => false,
124 }
125 }
126
127 pub(crate) fn tokens(&self) -> Vec<&str> {
129 match self {
130 Self::Hex(_) => Vec::new(),
131 Self::Token(name) => vec![name.as_str()],
132 Self::Mix(a, b, _) | Self::Pulse(a, b) => {
133 let mut names = a.tokens();
134 names.extend(b.tokens());
135 names
136 }
137 }
138 }
139}
140
141const MAX_NESTING: usize = 16;
144
145struct Parser<'a> {
146 text: &'a str,
147 pos: usize,
148 depth: usize,
149}
150
151impl Parser<'_> {
152 fn rest(&self) -> &str {
153 &self.text[self.pos..]
154 }
155
156 fn skip_space(&mut self) {
157 let trimmed = self.rest().trim_start();
158 self.pos = self.text.len() - trimmed.len();
159 }
160
161 fn eat(&mut self, literal: &str) -> bool {
162 self.skip_space();
163 if self.rest().starts_with(literal) {
164 self.pos += literal.len();
165 true
166 } else {
167 false
168 }
169 }
170
171 fn expect(&mut self, literal: &str) -> Result<(), String> {
172 if self.eat(literal) { Ok(()) } else { Err(format!("expected `{literal}` in colour `{}`", self.text)) }
173 }
174
175 fn take_while(&mut self, keep: impl Fn(char) -> bool) -> &str {
176 let start = self.pos;
177 let len = self.rest().find(|c: char| !keep(c)).unwrap_or(self.rest().len());
178 self.pos += len;
179 &self.text[start..self.pos]
180 }
181
182 fn nest(&mut self) -> Result<(), String> {
184 self.depth += 1;
185 if self.depth > MAX_NESTING {
186 return Err(format!("colour `{}` nests more than {MAX_NESTING} levels of mix() or pulse()", self.text));
187 }
188 Ok(())
189 }
190
191 fn expr(&mut self) -> Result<Expr, String> {
192 if self.eat("$") {
193 let name = self.take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
194 if name.is_empty() {
195 return Err(format!("missing token name after `$` in colour `{}`", self.text));
196 }
197 return Ok(Expr::Token(name.to_owned()));
198 }
199 if self.eat("#") {
200 let digits = self.take_while(|c| c.is_ascii_hexdigit()).to_owned();
201 return Rgb::parse_hex(&format!("#{digits}"))
202 .map(Expr::Hex)
203 .ok_or_else(|| format!("`#{digits}` is not a valid colour; use #RRGGBB or #RGB"));
204 }
205 if self.eat("mix(") {
206 self.nest()?;
207 let a = self.expr()?;
208 self.expect(",")?;
209 let b = self.expr()?;
210 self.expect(",")?;
211 self.skip_space();
212 let number = self.take_while(|c| c.is_ascii_digit() || c == '.').to_owned();
213 let percent: f32 =
214 number.parse().map_err(|_| format!("mix() needs a percentage like 30% in colour `{}`", self.text))?;
215 if !(0.0..=100.0).contains(&percent) {
216 return Err(format!("mix() percentage must be between 0% and 100%, got {number}%"));
217 }
218 self.expect("%")?;
219 self.expect(")")?;
220 self.depth -= 1;
221 return Ok(Expr::Mix(Box::new(a), Box::new(b), percent));
222 }
223 if self.eat("pulse(") {
224 self.nest()?;
225 let a = self.expr()?;
226 self.expect(",")?;
227 let b = self.expr()?;
228 self.expect(")")?;
229 self.depth -= 1;
230 return Ok(Expr::Pulse(Box::new(a), Box::new(b)));
231 }
232 Err(format!("`{}` is not a colour; use $token, #RRGGBB, mix(a, b, N%) or pulse(a, b)", self.text))
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn tokens() -> BTreeMap<String, Rgb> {
241 BTreeMap::from([("accent".to_owned(), Rgb::new(255, 255, 255)), ("surface".to_owned(), Rgb::new(0, 0, 0))])
242 }
243
244 #[test]
245 fn parses_every_form() {
246 assert_eq!(Expr::parse("#fff"), Ok(Expr::Hex(Rgb::new(255, 255, 255))));
247 assert_eq!(Expr::parse(" $accent-2 "), Ok(Expr::Token("accent-2".to_owned())));
248 let mix = Expr::parse("mix($accent, $surface, 34%)").expect("valid mix");
249 assert_eq!(mix.tokens(), vec!["accent", "surface"]);
250 assert!(matches!(Expr::parse("pulse($accent, #000)"), Ok(Expr::Pulse(..))));
251 }
252
253 #[test]
254 fn explains_bad_input() {
255 assert_eq!(Expr::parse("#38BDZ8").err().as_deref(), Some("`#38BD` is not a valid colour; use #RRGGBB or #RGB"));
256 assert!(Expr::parse("mix($a, $b)").is_err());
257 assert!(Expr::parse("mix($a, $b, 140%)").is_err());
258 assert!(Expr::parse("red").is_err());
259 assert!(Expr::parse("$accent extra").is_err());
260 }
261
262 #[test]
263 fn deep_nesting_is_an_error_not_a_stack_overflow() {
264 let nested = |levels: usize| format!("{}$accent{}", "mix(".repeat(levels), ", $surface, 50%)".repeat(levels));
265 assert!(Expr::parse(&nested(MAX_NESTING)).is_ok());
266 assert!(Expr::parse(&nested(MAX_NESTING + 1)).is_err_and(|message| message.contains("nests more than")));
267 assert!(Expr::parse(&nested(200_000)).is_err());
268 }
269
270 #[test]
271 fn resolves_mix_as_share_of_first_colour() {
272 let paint = Expr::parse("mix($accent, $surface, 25%)").and_then(|e| e.resolve(&tokens())).expect("resolves");
273 assert_eq!(paint, Paint::Solid(Rgb::new(64, 64, 64)));
274 }
275
276 #[test]
277 fn pulse_only_at_top_level() {
278 let nested = Expr::parse("mix(pulse($accent, $surface), $surface, 50%)").expect("parses");
279 assert!(nested.resolve(&tokens()).is_err());
280 let unknown = Expr::parse("$missing").expect("parses");
281 assert_eq!(unknown.resolve(&tokens()).err().as_deref(), Some("unknown colour token `$missing`"));
282 }
283
284 #[test]
285 fn pulse_breathes_between_ends() {
286 let paint = Paint::Pulse(Rgb::new(0, 0, 0), Rgb::new(200, 200, 200));
287 assert_eq!(paint.at(0.0), Rgb::new(0, 0, 0));
288 assert_eq!(paint.at(0.5), Rgb::new(200, 200, 200));
289 assert_eq!(paint.at(1.0), Rgb::new(0, 0, 0));
290 assert!(paint.is_animated());
291 assert!(!Paint::Solid(Rgb::new(1, 2, 3)).is_animated());
292 }
293}