Skip to main content

motion_core/animation/
keyframes.rs

1//! Finite tick-addressed keyframe tracks with explicit interpolation and holds.
2
3use super::{Animation, Easing};
4use crate::{Duration, Interpolate, Time};
5
6#[derive(Clone, Debug)]
7struct Keyframe<Value> {
8    at: Time,
9    value: Value,
10    easing: Option<Easing>,
11}
12
13/// A nonempty track starting at zero, with absolute destinations and relative holds.
14///
15/// Exact timestamps return stored values. Intervals use the arriving point's
16/// easing, while holds clone their value without invoking interpolation. Sampling
17/// is stateless and uses integer ticks for interval selection before f64 progress.
18///
19/// This is the canonical [`crate::Keyframes`] type.
20///
21/// ```
22/// use motion_core::prelude::*;
23/// use motion_core::{
24///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
25///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
26/// };
27/// let track = Keyframes::new(0.0_f64)
28///     .to(1.seconds(), 10.0, Easing::QuadraticIn)
29///     .hold(500.milliseconds())
30///     .to(2.5.seconds(), 20.0, Easing::Linear);
31/// assert_eq!(track.sample(500.milliseconds()), 2.5);
32/// assert_eq!(track.sample(1250.milliseconds()), 10.0);
33/// assert_eq!(track.sample(2.seconds()), 15.0);
34/// assert_eq!(track.duration(), Some(2.5.seconds()));
35/// ```
36#[derive(Clone, Debug)]
37pub struct Keyframes<Value> {
38    points: Vec<Keyframe<Value>>,
39}
40
41impl<Value> Keyframes<Value> {
42    /// Starts with a value at zero. Without further points the finite duration
43    /// is zero and every sample returns this value.
44    pub fn new(value: Value) -> Self {
45        Self {
46            points: vec![Keyframe {
47                at: Time::ZERO,
48                value,
49                easing: None,
50            }],
51        }
52    }
53
54    /// Appends a destination at an absolute local time, not an added duration.
55    /// The easing controls the segment arriving at this value.
56    ///
57    /// # Panics
58    /// Panics unless `at` is strictly later than the previous point, including
59    /// any appended hold, or for invalid easing parameters. Equal, negative
60    /// and descending times are not sorted or converted into instantaneous jumps.
61    pub fn to(mut self, at: Time, value: Value, easing: Easing) -> Self {
62        assert!(
63            at > self.last().at,
64            "keyframe times must be strictly increasing"
65        );
66        self.points.push(Keyframe {
67            at,
68            value,
69            easing: Some(easing.validated()),
70        });
71        self
72    }
73
74    fn last(&self) -> &Keyframe<Value> {
75        self.points
76            .last()
77            .expect("keyframes contain an initial value")
78    }
79}
80
81impl<Value: Clone> Keyframes<Value> {
82    /// Holds the last value for an additional duration, without interpolation.
83    /// Zero is a no-op, leaving the last timestamp unchanged.
84    ///
85    /// # Panics
86    /// Panics for a negative duration or signed tick overflow.
87    pub fn hold(mut self, duration: Duration) -> Self {
88        assert!(duration >= Time::ZERO, "keyframe hold must be nonnegative");
89        if duration == Time::ZERO {
90            return self;
91        }
92        let last = self.last();
93        let point = Keyframe {
94            at: last.at + duration,
95            value: last.value.clone(),
96            easing: None,
97        };
98        self.points.push(point);
99        self
100    }
101}
102
103impl<Value: Interpolate + Send + Sync> Animation<Value> for Keyframes<Value> {
104    fn sample(&self, time: Time) -> Value {
105        let upper = self.points.partition_point(|point| point.at <= time);
106        if upper == 0 {
107            return self.points[0].value.clone();
108        }
109        let left = &self.points[upper - 1];
110        if upper == self.points.len() || time == left.at {
111            return left.value.clone();
112        }
113        let right = &self.points[upper];
114        let Some(easing) = &right.easing else {
115            return left.value.clone();
116        };
117        let duration = right.at - left.at;
118        let progress = easing.apply((time - left.at) / duration);
119        left.value.interpolate(&right.value, progress)
120    }
121
122    fn duration(&self) -> Option<Duration> {
123        Some(self.last().at)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::{Point, TimeLiteral, Tween};
131
132    #[test]
133    fn absolute_destinations_relative_holds_and_arriving_easing_are_seekable() {
134        let track = Keyframes::new(0.0_f64)
135            .to(1.seconds(), 10.0, Easing::QuadraticIn)
136            .hold(500.milliseconds())
137            .to(2.5.seconds(), 20.0, Easing::Linear);
138        assert_eq!(track.duration(), Some(2.5.seconds()));
139        for (milliseconds, expected) in [
140            (2_000, 15.0),
141            (0, 0.0),
142            (500, 2.5),
143            (1_000, 10.0),
144            (1_250, 10.0),
145            (1_500, 10.0),
146            (2_500, 20.0),
147            (500, 2.5),
148        ] {
149            assert_eq!(track.sample(milliseconds.milliseconds()), expected);
150        }
151        assert_eq!(track.sample(Time::from_ticks(i64::MIN)), 0.0);
152        assert_eq!(track.sample(Time::from_ticks(i64::MAX)), 20.0);
153    }
154
155    #[test]
156    fn progress_and_adjacent_ticks_survive_large_absolute_timestamps() {
157        let track =
158            Keyframes::new(0.0_f64).to(Time::from_ticks(10_000_000_000), 1.0, Easing::Linear);
159        let progress = track.sample(Time::from_ticks(5_000_000_001));
160        assert_eq!(progress, 0.500_000_000_1);
161        assert_ne!(progress, f64::from(progress as f32));
162        let start = Time::from_ticks(1_i64 << 53);
163        let end = start + Time::from_ticks(3);
164        let track = Keyframes::new(0.0_f64)
165            .hold(start)
166            .to(end, 3.0, Easing::Linear)
167            .hold(Time::from_ticks(i64::MAX) - end);
168        assert_eq!(track.duration(), Some(Time::from_ticks(i64::MAX)));
169        assert_eq!(track.sample(start), 0.0);
170        assert_eq!(track.sample(start + Time::from_ticks(1)), 1.0);
171        assert_eq!(track.sample(start + Time::from_ticks(2)), 2.0);
172        assert_eq!(track.sample(end), 3.0);
173        assert_eq!(track.sample(Time::from_ticks(i64::MAX)), 3.0);
174        let last_tick = Keyframes::new(2.0_f64)
175            .hold(Time::from_ticks(i64::MAX - 1))
176            .to(Time::from_ticks(i64::MAX), 7.0, Easing::Linear);
177        assert_eq!(last_tick.sample(Time::from_ticks(i64::MAX - 1)), 2.0);
178        assert_eq!(last_tick.sample(Time::from_ticks(i64::MAX)), 7.0);
179    }
180
181    #[test]
182    fn holds_and_exact_points_never_call_value_interpolation() {
183        #[derive(Clone, Debug, PartialEq)]
184        struct ExactOnly(u32);
185
186        impl Interpolate for ExactOnly {
187            fn interpolate(&self, _target: &Self, _progress: f64) -> Self {
188                panic!("hold or exact timestamp must return the stored value");
189            }
190        }
191        let track = Keyframes::new(ExactOnly(1))
192            .to(Time::from_ticks(10), ExactOnly(2), Easing::QuadraticOut)
193            .hold(Time::from_ticks(5));
194        for (ticks, expected) in [
195            (i64::MIN, 1),
196            (0, 1),
197            (10, 2),
198            (12, 2),
199            (15, 2),
200            (i64::MAX, 2),
201        ] {
202            assert_eq!(track.sample(Time::from_ticks(ticks)), ExactOnly(expected));
203        }
204        let constant = Keyframes::new(ExactOnly(3));
205        assert_eq!(constant.duration(), Some(Time::ZERO));
206        assert_eq!(constant.sample(Time::ZERO), ExactOnly(3));
207        assert_eq!(constant.sample(Time::from_ticks(i64::MAX)), ExactOnly(3));
208    }
209
210    #[test]
211    fn invalid_order_negative_holds_and_overflow_fail_during_building() {
212        for ticks in [i64::MIN, -1, 0] {
213            assert!(
214                std::panic::catch_unwind(|| {
215                    Keyframes::new(0.0_f64).to(Time::from_ticks(ticks), 1.0, Easing::Linear)
216                })
217                .is_err()
218            );
219        }
220
221        fn track() -> Keyframes<f64> {
222            Keyframes::new(0.0_f64).hold(Time::from_ticks(10))
223        }
224        for ticks in [9, 10] {
225            assert!(
226                std::panic::catch_unwind(|| {
227                    track().to(Time::from_ticks(ticks), 1.0, Easing::Linear)
228                })
229                .is_err()
230            );
231        }
232        assert!(std::panic::catch_unwind(|| track().hold(Time::from_ticks(-1))).is_err());
233        let unchanged = track().hold(Time::ZERO);
234        assert_eq!(unchanged.points.len(), 2);
235        assert_eq!(unchanged.duration(), Some(Time::from_ticks(10)));
236        assert!(
237            std::panic::catch_unwind(|| {
238                Keyframes::new(0.0_f64)
239                    .hold(Time::from_ticks(i64::MAX))
240                    .hold(Time::from_ticks(1))
241            })
242            .is_err()
243        );
244    }
245
246    #[test]
247    fn tracks_use_existing_combinators_dynamic_dispatch_and_local_frames() -> crate::Result {
248        let track = crate::Keyframes::new(0.0_f64)
249            .to(1.seconds(), 10.0, Easing::Linear)
250            .hold(500.milliseconds())
251            .to(2.seconds(), 20.0, Easing::Linear);
252        let cache = crate::SceneCache::new(0);
253        let local = crate::SceneContext::new(
254            1500.milliseconds(),
255            crate::FrameRate::from(60),
256            crate::Size::new(64.0, 64.0),
257            &cache,
258        )
259        .shifted(1.seconds());
260        assert_eq!(local.sample(&track), 5.0);
261        let boxed = track
262            .clone()
263            .map(|value| Point::new(value, value * 2.0))
264            .boxed();
265        assert_eq!(local.sample(boxed.as_ref()), Point::new(5.0, 10.0));
266        assert_eq!(track.clone().reverse().sample(500.milliseconds()), 10.0);
267        let repeated = track.clone().repeat(2).delay(250.milliseconds());
268        assert_eq!(repeated.duration(), Some(4.25.seconds()));
269        assert_eq!(repeated.sample(2.25.seconds()), 0.0);
270        assert_eq!(repeated.sample(4.25.seconds()), 20.0);
271        let joined = track.then(Tween::new(20.0_f64, 40.0, 1.seconds()));
272        assert_eq!(joined.duration(), Some(3.seconds()));
273        assert_eq!(joined.sample(2.5.seconds()), 30.0);
274        assert_eq!(
275            std::thread::spawn(move || boxed.sample(1.25.seconds()))
276                .join()
277                .unwrap(),
278            Point::new(10.0, 20.0)
279        );
280        Ok(())
281    }
282
283    #[test]
284    fn moving_segments_match_double_precision_tweens_including_overshoot() {
285        for easing in [
286            Easing::Linear,
287            Easing::QuadraticIn,
288            Easing::QuadraticOut,
289            Easing::QuadraticInOut,
290            Easing::CubicBezier {
291                x1: 0.3,
292                y1: 1.8,
293                x2: 0.7,
294                y2: 1.8,
295            },
296        ] {
297            let track = Keyframes::new(10.0_f64).hold(300.milliseconds()).to(
298                2.3.seconds(),
299                30.0,
300                easing.clone(),
301            );
302            let tween = Tween::new(10.0_f64, 30.0, 2.seconds()).easing(easing);
303            for milliseconds in [1_000, 0, 1, 500, 1_999, 2_000] {
304                let time = milliseconds.milliseconds();
305                assert_eq!(track.sample(300.milliseconds() + time), tween.sample(time));
306            }
307        }
308    }
309}