Skip to main content

telar_motion_core/
keyframes.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::{Duration, Instant};
4
5use reactive_core::{ReadSignal, RwSignal, signal};
6
7use crate::easing::Easing;
8use crate::lerp::Lerp;
9use crate::ticker::{self, Tickable};
10
11/// How a [`Keyframes`] sequence behaves once it reaches its last step.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum Repeat {
14    Once,
15    Loop,
16    PingPong,
17}
18
19// One leg of the sequence: interpolate `start` -> `end` over `duration` under `easing`.
20// `hold()` emits a step with start == end, so its easing is inert by construction.
21struct Step<T: Lerp> {
22    start: T,
23    end: T,
24    duration: Duration,
25    easing: Easing,
26}
27
28// Which way the timeline cursor is currently moving; only meaningful for `Repeat::PingPong`.
29#[derive(Clone, Copy, PartialEq)]
30enum Direction {
31    Forward,
32    Backward,
33}
34
35// Find the step spanning timeline position `t` (assumed clamped to `[0, total]`), returning its
36// index and its cumulative `[start, end)` bounds.
37fn locate<T: Lerp>(steps: &[Step<T>], t: f32) -> (usize, f32, f32) {
38    let mut acc = 0.0;
39    let last_idx = steps.len() - 1;
40    for (i, step) in steps.iter().enumerate() {
41        let end_cum = acc + step.duration.as_secs_f32();
42        if t <= end_cum || i == last_idx {
43            return (i, acc, end_cum);
44        }
45        acc = end_cum;
46    }
47    unreachable!("steps is never empty: KeyframesBuilder::start() guarantees at least one step")
48}
49
50// Pure function of timeline position: the same curve is reused for forward and backward travel,
51// so PingPong "mirrors" a step's easing simply by re-evaluating it as `t` decreases (see Design note on Keyframes).
52fn value_at<T: Lerp>(steps: &[Step<T>], t: f32) -> T {
53    let (idx, start_cum, end_cum) = locate(steps, t);
54    let step = &steps[idx];
55    let dur = end_cum - start_cum;
56    let local = if dur <= 0.0 {
57        1.0
58    } else {
59        ((t - start_cum) / dur).clamp(0.0, 1.0)
60    };
61    step.start.lerp(&step.end, step.easing.apply(local))
62}
63
64pub(crate) struct KeyframesInner<T: Lerp + 'static> {
65    signal: RwSignal<T>,
66    steps: Vec<Step<T>>,
67    total_duration: f32,
68    initial: T,
69    repeat: Repeat,
70    direction: Direction,
71    // Position along the `[0, total_duration]` timeline; for PingPong this itself moves back and forth.
72    elapsed_secs: f32,
73    current: T,
74    // Doubles as Tickable::is_settled: true once Once completes naturally OR `stop()` was called.
75    settled: bool,
76    // True only when Repeat::Once ran its sequence to completion (never set by `stop()`).
77    completed_once: bool,
78    last: Option<Instant>,
79}
80
81impl<T: Lerp + 'static> KeyframesInner<T> {
82    fn integrate(&mut self, now: Instant, scale: f32) -> Option<T> {
83        if self.settled {
84            return None;
85        }
86        if scale <= 0.0 {
87            return Some(self.snap_scale_zero());
88        }
89        let last = match self.last {
90            Some(last) => last,
91            None => {
92                self.last = Some(now);
93                return None;
94            }
95        };
96        self.last = Some(now);
97        let dt = now.saturating_duration_since(last).as_secs_f32() * scale;
98        if dt <= 0.0 {
99            return None;
100        }
101        // All-zero-duration sequence: Once settles instantly; Loop/PingPong just hold to avoid a `% 0.0`.
102        if self.total_duration <= 0.0 {
103            return matches!(self.repeat, Repeat::Once).then(|| self.finish_once());
104        }
105        self.advance(dt);
106        if matches!(self.repeat, Repeat::Once) && self.elapsed_secs >= self.total_duration {
107            return Some(self.finish_once());
108        }
109        self.current = value_at(&self.steps, self.elapsed_secs);
110        Some(self.current.clone())
111    }
112
113    fn advance(&mut self, dt: f32) {
114        match self.repeat {
115            Repeat::Once => self.elapsed_secs = (self.elapsed_secs + dt).min(self.total_duration),
116            // Wrapping back to 0 replays the first step's start value, which is a deliberate discrete
117            // jump if it differs from the last step's end (CSS-style restart, not a smoothed loop).
118            Repeat::Loop => self.elapsed_secs = (self.elapsed_secs + dt) % self.total_duration,
119            Repeat::PingPong => {
120                let mut remaining = dt;
121                while remaining > 0.0 {
122                    match self.direction {
123                        Direction::Forward => {
124                            let to_edge = self.total_duration - self.elapsed_secs;
125                            if remaining < to_edge {
126                                self.elapsed_secs += remaining;
127                                remaining = 0.0;
128                            } else {
129                                self.elapsed_secs = self.total_duration;
130                                remaining -= to_edge;
131                                self.direction = Direction::Backward;
132                            }
133                        }
134                        Direction::Backward => {
135                            let to_edge = self.elapsed_secs;
136                            if remaining < to_edge {
137                                self.elapsed_secs -= remaining;
138                                remaining = 0.0;
139                            } else {
140                                self.elapsed_secs = 0.0;
141                                remaining -= to_edge;
142                                self.direction = Direction::Forward;
143                            }
144                        }
145                    }
146                }
147            }
148        }
149    }
150
151    fn finish_once(&mut self) -> T {
152        self.elapsed_secs = self.total_duration;
153        self.current = self.steps.last().expect("steps is never empty").end.clone();
154        self.completed_once = true;
155        self.settled = true;
156        self.current.clone()
157    }
158
159    // scale == 0.0 (reduced-motion "instant"): Once jumps to the sequence end; Loop/PingPong jump to
160    // the end of whichever step is in flight (simplest choice that stays coherent with `Animated`'s
161    // snap-to-target without collapsing an indefinite repeat to a single frozen frame).
162    fn snap_scale_zero(&mut self) -> T {
163        if matches!(self.repeat, Repeat::Once) {
164            return self.finish_once();
165        }
166        let (_, start_cum, end_cum) = locate(&self.steps, self.elapsed_secs);
167        self.elapsed_secs = match self.direction {
168            Direction::Forward => end_cum,
169            Direction::Backward => start_cum,
170        };
171        self.current = value_at(&self.steps, self.elapsed_secs);
172        self.current.clone()
173    }
174}
175
176impl<T: Lerp + 'static> Tickable for RefCell<KeyframesInner<T>> {
177    fn tick(&self, now: Instant, scale: f32) {
178        // As in Animated: `.set()` runs outside the borrow so a re-entrant read/control call cannot hit a live borrow.
179        let (signal, value) = {
180            let mut inner = self.borrow_mut();
181            let value = inner.integrate(now, scale);
182            (inner.signal.clone(), value)
183        };
184        if let Some(value) = value {
185            signal.set(value);
186        }
187    }
188
189    fn is_settled(&self) -> bool {
190        self.borrow().settled
191    }
192}
193
194/// A signal-backed, autonomous multi-step animation: it plays a fixed sequence rather than chasing a
195/// live target, driven by the same central ticker as [`crate::Animated`].
196pub struct Keyframes<T: Lerp + 'static> {
197    inner: Rc<RefCell<KeyframesInner<T>>>,
198    id: u64,
199}
200
201impl<T: Lerp + 'static> Clone for Keyframes<T> {
202    fn clone(&self) -> Self {
203        Keyframes {
204            inner: Rc::clone(&self.inner),
205            id: self.id,
206        }
207    }
208}
209
210impl<T: Lerp + 'static> Keyframes<T> {
211    /// Start building a sequence resting at `initial`.
212    // Returns a builder rather than Self by design (entry point of the builder chain, cf. Animated::new).
213    #[allow(clippy::new_ret_no_self)]
214    pub fn new(initial: T) -> KeyframesBuilder<T> {
215        KeyframesBuilder {
216            initial: initial.clone(),
217            cursor: initial,
218            steps: Vec::new(),
219        }
220    }
221
222    /// Reactive read: subscribes the calling segment to the current value.
223    pub fn get(&self) -> T {
224        self.inner.borrow().signal.get()
225    }
226
227    /// A read-only handle to the underlying signal.
228    pub fn read(&self) -> ReadSignal<T> {
229        self.inner.borrow().signal.read_only()
230    }
231
232    /// Rewind to t=0 and (re)register with the ticker, whatever the current state.
233    pub fn restart(&self) {
234        // The set happens outside the borrow, like tick(): at batch depth 0 it flushes synchronously and a subscribed segment's re-entrant get() needs the RefCell.
235        let (signal, initial) = {
236            let mut inner = self.inner.borrow_mut();
237            inner.elapsed_secs = 0.0;
238            inner.direction = Direction::Forward;
239            inner.current = inner.initial.clone();
240            inner.settled = false;
241            inner.completed_once = false;
242            // Re-establish t0 on the next tick, same reasoning as Animated::retarget.
243            inner.last = None;
244            (inner.signal.clone(), inner.current.clone())
245        };
246        // Registration is idempotent (keyed by id), so re-registering an already-active sequence is harmless. Register before the set so a re-entrant has_active() during the flush already sees it active.
247        let weak = Rc::downgrade(&self.inner);
248        ticker::register(self.id, weak);
249        signal.set(initial);
250    }
251
252    /// Stop advancing and freeze at the current value; deregisters from the ticker.
253    pub fn stop(&self) {
254        let mut inner = self.inner.borrow_mut();
255        inner.settled = true;
256        inner.last = None;
257    }
258
259    /// True only once a `Repeat::Once` sequence has played through to its last step.
260    pub fn is_finished(&self) -> bool {
261        self.inner.borrow().completed_once
262    }
263}
264
265/// Accumulates steps for a [`Keyframes`] sequence before it starts.
266pub struct KeyframesBuilder<T: Lerp + 'static> {
267    initial: T,
268    // Running end value of the last appended step (or `initial` if none yet), so the next step knows its start.
269    cursor: T,
270    steps: Vec<Step<T>>,
271}
272
273impl<T: Lerp + 'static> KeyframesBuilder<T> {
274    /// Append a step interpolating from the current end of the sequence to `value`.
275    pub fn then(mut self, value: T, duration: Duration, easing: Easing) -> Self {
276        self.steps.push(Step {
277            start: self.cursor,
278            end: value.clone(),
279            duration,
280            easing,
281        });
282        self.cursor = value;
283        self
284    }
285
286    /// Append a step that holds the current value for `duration` (delay / stagger).
287    pub fn hold(mut self, duration: Duration) -> Self {
288        self.steps.push(Step {
289            start: self.cursor.clone(),
290            end: self.cursor.clone(),
291            duration,
292            easing: Easing::Linear,
293        });
294        self
295    }
296
297    /// Register the sequence with the ticker and start playback under `repeat`.
298    pub fn start(self, repeat: Repeat) -> Keyframes<T> {
299        let steps = if self.steps.is_empty() {
300            // A sequence needs at least one step so `locate`/`value_at` never see an empty slice.
301            vec![Step {
302                start: self.initial.clone(),
303                end: self.initial.clone(),
304                duration: Duration::ZERO,
305                easing: Easing::Linear,
306            }]
307        } else {
308            self.steps
309        };
310        let total_duration = steps.iter().map(|s| s.duration.as_secs_f32()).sum();
311        let signal = signal(self.initial.clone());
312        let inner = Rc::new(RefCell::new(KeyframesInner {
313            signal,
314            steps,
315            total_duration,
316            initial: self.initial.clone(),
317            repeat,
318            direction: Direction::Forward,
319            elapsed_secs: 0.0,
320            current: self.initial,
321            settled: false,
322            completed_once: false,
323            last: None,
324        }));
325        let kf = Keyframes {
326            inner,
327            id: ticker::next_id(),
328        };
329        let weak = Rc::downgrade(&kf.inner);
330        ticker::register(kf.id, weak);
331        kf
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::curve::{spring, tween};
339    use crate::ticker::{has_active, reset, set_scale, tick};
340    use crate::{Animated, Easing};
341
342    fn fresh() -> Instant {
343        reset();
344        set_scale(1.0);
345        Instant::now()
346    }
347
348    // Regression: restart()'s signal set flushes synchronously at batch depth 0, re-running any subscribed effect that calls get() — which must not hit a live RefCell borrow (the sandbox Replay button panicked here).
349    #[test]
350    fn restart_with_subscribed_effect_does_not_reentrantly_panic() {
351        use std::cell::Cell;
352        use std::rc::Rc;
353        let base = fresh();
354        let kf = Keyframes::new(0.0f32)
355            .then(10.0, Duration::from_millis(100), Easing::Linear)
356            .start(Repeat::Once);
357        let seen = Rc::new(Cell::new(-1.0f32));
358        let seen_c = Rc::clone(&seen);
359        let kf_read = kf.clone();
360        let _e = reactive_core::effect(move || seen_c.set(kf_read.get()));
361        tick(base);
362        tick(base + Duration::from_millis(200));
363        assert!(kf.is_finished());
364        assert_eq!(seen.get(), 10.0);
365
366        kf.restart();
367        assert_eq!(
368            seen.get(),
369            0.0,
370            "effect observes the reset value during restart's flush"
371        );
372        assert!(has_active());
373        tick(base + Duration::from_millis(300));
374        tick(base + Duration::from_millis(350));
375        assert_eq!(seen.get(), 5.0, "sequence replays after restart");
376    }
377
378    #[test]
379    fn once_respects_easing_mid_step_then_chains_then_holds_then_settles() {
380        let base = fresh();
381        let kf = Keyframes::new(0.0f32)
382            .then(1.0, Duration::from_millis(200), Easing::EaseInOut)
383            .then(2.0, Duration::from_millis(100), Easing::Linear)
384            .hold(Duration::from_millis(50))
385            .start(Repeat::Once);
386        tick(base); // establishes t0, no movement yet
387        assert_eq!(kf.get(), 0.0);
388
389        // Mid first step: value should match the eased (not linear) progress.
390        tick(base + Duration::from_millis(100));
391        let expected = 0.0f32.lerp(&1.0, Easing::EaseInOut.apply(0.5));
392        assert!((kf.get() - expected).abs() < 1e-4, "{}", kf.get());
393
394        // Into the second (linear) step: 200ms + 50ms = 50% through a 100ms step from 1.0 -> 2.0.
395        tick(base + Duration::from_millis(250));
396        assert!((kf.get() - 1.5).abs() < 1e-4, "{}", kf.get());
397
398        // Inside the hold: value stays at the previous end (2.0).
399        tick(base + Duration::from_millis(320));
400        assert!((kf.get() - 2.0).abs() < 1e-4, "{}", kf.get());
401        assert!(has_active());
402        assert!(!kf.is_finished());
403
404        // Past the total duration (350ms): settles at the final value and deregisters.
405        tick(base + Duration::from_millis(400));
406        assert!((kf.get() - 2.0).abs() < 1e-6);
407        assert!(kf.is_finished());
408        assert!(!has_active());
409    }
410
411    #[test]
412    fn loop_wraps_with_a_discrete_jump_and_stays_active() {
413        let base = fresh();
414        // Single 100ms linear leg 0 -> 1; looping restarts at 0, a deliberate discrete jump from 1.
415        let kf = Keyframes::new(0.0f32)
416            .then(1.0, Duration::from_millis(100), Easing::Linear)
417            .start(Repeat::Loop);
418        tick(base);
419        // 2.5 cycles later we should be 50% into the (2.5 mod 1 = 0.5) third cycle.
420        tick(base + Duration::from_millis(250));
421        assert!((kf.get() - 0.5).abs() < 1e-4, "{}", kf.get());
422        assert!(has_active(), "Loop must stay registered indefinitely");
423    }
424
425    #[test]
426    fn pingpong_reverses_and_decreases_on_the_way_back() {
427        let base = fresh();
428        let kf = Keyframes::new(0.0f32)
429            .then(1.0, Duration::from_millis(100), Easing::Linear)
430            .start(Repeat::PingPong);
431        tick(base);
432        // Exactly at the far end: turnaround point.
433        tick(base + Duration::from_millis(100));
434        assert!((kf.get() - 1.0).abs() < 1e-4, "{}", kf.get());
435        // 20ms back into the reverse pass: value must have decreased from the peak.
436        tick(base + Duration::from_millis(120));
437        assert!(kf.get() < 1.0, "did not decrease: {}", kf.get());
438        assert!((kf.get() - 0.8).abs() < 1e-4, "{}", kf.get());
439        assert!(has_active(), "PingPong must stay registered indefinitely");
440    }
441
442    #[test]
443    fn restart_after_finished_resets_and_reregisters() {
444        let base = fresh();
445        let kf = Keyframes::new(0.0f32)
446            .then(1.0, Duration::from_millis(100), Easing::Linear)
447            .start(Repeat::Once);
448        tick(base);
449        tick(base + Duration::from_millis(100));
450        assert!(kf.is_finished());
451        assert!(!has_active());
452
453        kf.restart();
454        assert_eq!(kf.get(), 0.0);
455        assert!(!kf.is_finished());
456        assert!(has_active());
457
458        tick(base + Duration::from_millis(200)); // re-establishes t0 after restart
459        tick(base + Duration::from_millis(250));
460        assert!((kf.get() - 0.5).abs() < 1e-4, "{}", kf.get());
461    }
462
463    #[test]
464    fn stop_deregisters_and_freezes_without_marking_finished() {
465        let base = fresh();
466        let kf = Keyframes::new(0.0f32)
467            .then(1.0, Duration::from_millis(200), Easing::Linear)
468            .start(Repeat::Once);
469        tick(base);
470        tick(base + Duration::from_millis(100));
471        assert!((kf.get() - 0.5).abs() < 1e-4);
472
473        kf.stop();
474        assert!(!has_active());
475        assert!(!kf.is_finished(), "stop() is not natural completion");
476        assert!((kf.get() - 0.5).abs() < 1e-4, "value moved after stop");
477    }
478
479    #[test]
480    fn spring_presets_build_expected_values() {
481        assert_eq!(crate::Spring::gentle(), spring(120.0, 14.0));
482        assert_eq!(crate::Spring::snappy(), spring(210.0, 20.0));
483        assert_eq!(crate::Spring::bouncy(), spring(180.0, 12.0));
484    }
485
486    #[test]
487    fn ticker_tracks_animated_and_keyframes_together() {
488        let base = fresh();
489        let anim = Animated::new(0.0f32, tween(Duration::from_millis(100), Easing::Linear));
490        anim.retarget(1.0);
491        let kf = Keyframes::new(0.0f32)
492            .then(1.0, Duration::from_millis(100), Easing::Linear)
493            .start(Repeat::Loop);
494
495        tick(base);
496        assert!(has_active());
497        tick(base + Duration::from_millis(50));
498        assert!((anim.get() - 0.5).abs() < 1e-4);
499        assert!((kf.get() - 0.5).abs() < 1e-4);
500        assert!(has_active());
501
502        // The tween settles at 100ms but the Loop keeps the registry non-empty.
503        tick(base + Duration::from_millis(100));
504        assert!((anim.get() - 1.0).abs() < 1e-6);
505        assert!(anim.is_settled());
506        assert!(has_active(), "Keyframes loop must keep the ticker active");
507
508        kf.stop();
509        assert!(!has_active());
510    }
511}