Skip to main content

qframe/theme/
paint.rs

1//! Colour expressions used in theme files, and the paints they resolve to.
2//!
3//! Grammar:
4//!
5//! ```text
6//! expr  = token | hex | mix | pulse
7//! token = "$" name                       e.g. $accent-2
8//! hex   = "#" 3 or 6 hex digits          e.g. #38BDF8
9//! mix   = "mix(" expr "," expr "," number "%" ")"
10//! pulse = "pulse(" expr "," expr ")"
11//! ```
12//!
13//! `mix(a, b, 30%)` is 30% of `a` blended into `b`. `pulse(a, b)` breathes between `a` and
14//! `b`; it can only appear at the top of an expression.
15
16use std::collections::BTreeMap;
17use std::f32::consts::TAU;
18
19use crate::color::Rgb;
20
21/// A resolved colour, possibly animated.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum Paint {
24    /// A single colour.
25    Solid(Rgb),
26    /// A colour that breathes between two ends over the theme's pulse period.
27    Pulse(Rgb, Rgb),
28}
29
30impl Paint {
31    /// The colour at `phase` of the pulse cycle (`0.0..1.0`, wrapping).
32    ///
33    /// The pulse starts at its first colour, reaches the second at `0.5` and returns.
34    #[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    /// Whether drawing this paint needs animation frames.
46    #[must_use]
47    pub fn is_animated(self) -> bool {
48        matches!(self, Self::Pulse(..))
49    }
50}
51
52/// A parsed, unresolved colour expression.
53#[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
61// Percentages are checked to lie in 0..=100 while parsing, so no expression holds a NaN.
62impl 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    /// Resolves to a paint. `tokens` holds already resolved colour tokens.
76    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    /// Resolves to a single colour; `pulse()` is not allowed here.
84    pub(crate) fn solid(&self, tokens: &BTreeMap<String, Rgb>) -> Result<Rgb, String> {
85        self.solid_by(&|name| tokens.get(name).copied())
86    }
87
88    /// Resolves to a paint, looking tokens up with `lookup`.
89    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    /// Resolves to a single colour, looking tokens up with `lookup`; `pulse()` is not allowed here.
97    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    /// The error for a `pulse()` nested inside another colour, which no lookup can resolve.
107    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    /// Names of the tokens this expression reads.
128    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
141/// How deeply `mix()` and `pulse()` may nest. Real themes use two or three levels; the limit keeps
142/// a hostile file from recursing the parser into a stack overflow.
143const 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    /// Enters one more level of `mix()` or `pulse()`.
183    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}