Skip to main content

kui_core/
keyframes.rs

1//! Keyframes: CSS `@keyframes` for a node's animatable slots. A node with
2//! `NodeSpec::keyframes` cycles those slots through the stops over its
3//! transition's duration, in the transition's `repeat` direction, held
4//! back by its `delay_ms` — CSS's `animation-*` family on a kui node.
5//!
6//! The rules are CSS's (and the Web Animations API's) where they had one:
7//! a stop's `at` is optional and missing ones spread evenly between their
8//! neighbours (the last defaults to 1, the first to 0 — a lone stop is the
9//! far end and animates from the node's own value); a slot a stop
10//! leaves out is simply not part of that stop; and a slot whose stops don't
11//! reach 0 or 1 gets the node's own declared value there, so a single
12//! `{ at: 0.5, bg }` stop is a pulse to that color and back.
13//!
14//! Stops arrive as plain data from the bindings (`parse`), and the runtime
15//! flattens them per slot into the tracks [`crate::anim::AnimStore::sample`]
16//! walks.
17
18use crate::slots::{Slots, slot_builders};
19use crate::value::Value;
20
21/// One stop. Every field is optional: `at` resolves by position, and a
22/// slot a stop doesn't name is left to its neighbours. Derefs to its
23/// [`Slots`], so `stop.bg` reads the slot.
24#[derive(Clone, Copy, Debug, Default, PartialEq)]
25pub struct Keyframe {
26    /// Position in the cycle, 0..=1; None spreads evenly.
27    pub at: Option<f32>,
28    /// Width, height, bg, radius and opacity — the slots an entrance names
29    /// too.
30    pub slots: Slots,
31}
32
33slot_builders!(Keyframe);
34
35impl Keyframe {
36    pub fn at(mut self, at: f32) -> Self {
37        self.at = Some(at);
38        self
39    }
40}
41
42/// Stops from plain data: a list of maps with any of `at`, `width`,
43/// `height`, `bg`, `radius`, `opacity`, in the forms the props themselves take
44/// (sizings as a number, `"grow"`, `"50%"`, `{grow}` / `{percent}`;
45/// colors as `0xRRGGBBAA` or `"#hex"`). Every binding funnels its
46/// keyframes through here, so the shape is the same in JSX, Lua and C.
47pub fn parse(v: &Value) -> Result<Vec<Keyframe>, String> {
48    parse_with(v, None)
49}
50
51/// [`parse`] with a token lookup: a `$name` in a stop's `width`,
52/// `height`, `bg` or `radius` resolves through `refs`, and one that
53/// misses leaves that slot unnamed and is remembered on the refs for the
54/// binding to raise (backlog AR14). Without refs a `$name` is the error
55/// it always was, since there is nothing to resolve it against.
56pub fn parse_with(
57    v: &Value,
58    mut refs: Option<&mut crate::tokens::NameRefs<'_>>,
59) -> Result<Vec<Keyframe>, String> {
60    let Value::List(stops) = v else {
61        return Err("keyframes must be a list of stops".into());
62    };
63    let mut frames = Vec::with_capacity(stops.len());
64    let mut last_at = 0.0f32;
65    for (i, stop) in stops.iter().enumerate() {
66        let Value::Map(fields) = stop else {
67            return Err(format!("keyframe {i} must be an object"));
68        };
69        let mut kf = Keyframe::default();
70        for (k, v) in fields {
71            let bad = |what: &str| format!("keyframe {i}: {what}");
72            if kf
73                .slots
74                .parse_field(k, v, refs.as_deref_mut())
75                .map_err(|e| bad(&e))?
76            {
77                continue;
78            }
79            match k.as_str() {
80                "at" => {
81                    let at = v
82                        .as_float()
83                        .ok_or_else(|| bad("at must be a number 0..1"))?
84                        as f32;
85                    if !(0.0..=1.0).contains(&at) {
86                        return Err(bad("at must be within 0..1"));
87                    }
88                    if at < last_at {
89                        return Err(bad("at must not decrease"));
90                    }
91                    last_at = at;
92                    kf.at = Some(at);
93                }
94                other => return Err(bad(&format!("unknown field {other:?}"))),
95            }
96        }
97        frames.push(kf);
98    }
99    Ok(frames)
100}
101
102/// Every stop's position: declared `at`s kept, the rest spread evenly
103/// between the nearest declared neighbours (0 and 1 at the ends).
104pub fn offsets(frames: &[Keyframe]) -> Vec<f32> {
105    let n = frames.len();
106    let mut out: Vec<f32> = frames.iter().map(|f| f.at.unwrap_or(f32::NAN)).collect();
107    if n == 0 {
108        return out;
109    }
110    // WAAPI's rule: the last stop defaults to 1 and, given company, the
111    // first to 0 — so a lone stop sits at 1 and animates from the base.
112    if n > 1 && out[0].is_nan() {
113        out[0] = 0.0;
114    }
115    if out[n - 1].is_nan() {
116        out[n - 1] = 1.0;
117    }
118    let mut i = 0;
119    while i < n {
120        if !out[i].is_nan() {
121            i += 1;
122            continue;
123        }
124        // A run of undeclared stops between two declared ones.
125        let start = i - 1;
126        let mut end = i;
127        while out[end].is_nan() {
128            end += 1;
129        }
130        let (a, b) = (out[start], out[end]);
131        let span = (end - start) as f32;
132        for (j, slot) in out[start + 1..end].iter_mut().enumerate() {
133            *slot = a + (b - a) * (j + 1) as f32 / span;
134        }
135        i = end;
136    }
137    out
138}
139
140/// The stops that name one slot, flattened into a track with the node's
141/// declared value (`base`) filling in the ends CSS would synthesize — a
142/// [`crate::anim::Track`]. None when no stop names the slot: it isn't
143/// keyframed and tweens as usual.
144pub(crate) fn track(
145    frames: &[Keyframe],
146    offsets: &[f32],
147    base: [f32; 4],
148    pick: impl Fn(&Keyframe) -> Option<[f32; 4]>,
149) -> Option<Vec<(f32, [f32; 4])>> {
150    let mut out: Vec<(f32, [f32; 4])> = Vec::with_capacity(frames.len() + 2);
151    for (f, &at) in frames.iter().zip(offsets) {
152        if let Some(v) = pick(f) {
153            out.push((at, v));
154        }
155    }
156    if out.is_empty() {
157        return None;
158    }
159    if out[0].0 > 0.0 {
160        out.insert(0, (0.0, base));
161    }
162    if out[out.len() - 1].0 < 1.0 {
163        out.push((1.0, base));
164    }
165    Some(out)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::color::Color;
172    use crate::spec::Sizing;
173
174    fn stop(fields: &[(&'static str, Value)]) -> Value {
175        Value::map(fields.iter().cloned())
176    }
177
178    fn list(stops: Vec<Value>) -> Value {
179        Value::List(stops)
180    }
181
182    #[test]
183    fn offsets_spread_evenly_between_declared_stops() {
184        let f = vec![Keyframe::default(); 3];
185        assert_eq!(offsets(&f), vec![0.0, 0.5, 1.0]);
186        let f = vec![
187            Keyframe::default().at(0.2),
188            Keyframe::default(),
189            Keyframe::default(),
190            Keyframe::default().at(0.8),
191            Keyframe::default(),
192        ];
193        let o = offsets(&f);
194        assert!(
195            (o[1] - 0.4).abs() < 1e-6 && (o[2] - 0.6).abs() < 1e-6,
196            "{o:?}"
197        );
198        assert_eq!(o[4], 1.0);
199        assert_eq!(offsets(&[Keyframe::default().at(0.5)]), vec![0.5]);
200        assert_eq!(
201            offsets(&[Keyframe::default()]),
202            vec![1.0],
203            "a lone stop is the far end"
204        );
205        assert!(offsets(&[]).is_empty());
206    }
207
208    #[test]
209    fn tracks_fill_the_ends_with_the_base_value() {
210        let f = [Keyframe::default().at(0.5).radius(8.0)];
211        let o = offsets(&f);
212        let t = track(&f, &o, [2.0; 4], |k| k.radius.map(|r| [r; 4])).unwrap();
213        assert_eq!(t, vec![(0.0, [2.0; 4]), (0.5, [8.0; 4]), (1.0, [2.0; 4])]);
214        assert!(track(&f, &o, [0.0; 4], |k| k.bg.map(|_| [0.0; 4])).is_none());
215    }
216
217    #[test]
218    fn parses_prop_shaped_values() {
219        let f = parse(&list(vec![
220            stop(&[("width", Value::map([("grow", Value::Int(0))]))]),
221            stop(&[
222                ("at", Value::Int(1)),
223                ("width", Value::str("grow")),
224                ("bg", Value::str("#ff0000")),
225                ("radius", Value::Int(3)),
226            ]),
227        ]))
228        .unwrap();
229        assert_eq!(f[0], Keyframe::default().width(Sizing::Grow(0.0)));
230        assert_eq!(
231            f[1],
232            Keyframe::default()
233                .at(1.0)
234                .grow_width()
235                .bg(Color::hex(0xff0000ff))
236                .radius(3.0)
237        );
238        let f = parse(&list(vec![stop(&[
239            ("height", Value::str("50%")),
240            ("bg", Value::Int(0xffffffff)),
241        ])]))
242        .unwrap();
243        assert_eq!(f[0].height, Some(Sizing::Percent(0.5)));
244        assert_eq!(f[0].bg, Some(Color::hex(0xffffffff)));
245        // The Lua percent spelling.
246        let f = parse(&list(vec![stop(&[(
247            "width",
248            Value::map([("pct", Value::Int(25))]),
249        )])]))
250        .unwrap();
251        assert_eq!(f[0].width, Some(Sizing::Percent(0.25)));
252    }
253
254    #[test]
255    fn rejects_what_css_would() {
256        let bad = |v: Value| parse(&v).unwrap_err();
257        assert!(bad(stop(&[("at", Value::Int(0))])).contains("list"));
258        assert!(
259            bad(list(vec![
260                stop(&[("at", Value::Float(0.5))]),
261                stop(&[("at", Value::Float(0.2))])
262            ]))
263            .contains("decrease")
264        );
265        assert!(bad(list(vec![stop(&[("at", Value::Int(2))])])).contains("within"));
266        assert!(bad(list(vec![stop(&[("colour", Value::str("#fff"))])])).contains("unknown field"));
267        assert!(bad(list(vec![stop(&[("width", Value::str("wide"))])])).contains("bad sizing"));
268    }
269}