Skip to main content

kui_core/
keyframes.rs

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