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