Skip to main content

rlvgl_core/
anim.rs

1//! Tick-driven tween/animation system (ANIM initiative).
2//!
3//! A [`Tween`] describes one scalar moving `from → to` over a duration
4//! measured **in ticks** ([`crate::event::Event::Tick`] dispatches), under
5//! an [`Easing`] curve and a [`LoopMode`]. Sampling is pure and
6//! deterministic: no wall clock anywhere — callers convert milliseconds to
7//! ticks at their loop edge. Headless harnesses can step animations
8//! frame-exactly and observe bit-identical values across runs.
9//!
10//! The [`Animations`] registry owns running tweens bound to apply
11//! callbacks, advances all of them on each tick, auto-removes completed
12//! entries, accumulates the dirty rects reported by the callbacks, and
13//! reports whether anything is still active (so dirty-rect planners know a
14//! repaint is pending).
15//!
16//! Normative spec: `docs/concepts/ANIM-00-CONCEPTS.md`. The legacy
17//! millisecond-based types in [`crate::animation`] remain unchanged; this
18//! module reuses their [`Easing`] / [`LoopMode`] vocabulary so the two
19//! surfaces cannot drift numerically.
20
21use alloc::boxed::Box;
22use alloc::vec::Vec;
23
24use crate::animation::loop_progress;
25pub use crate::animation::{Easing, LoopMode};
26use crate::widget::{Color, Rect};
27
28/// Fixed-point denominator used by the convenience adapters' internal
29/// progress tweens (`0..=ANIM_SCALE`).
30pub const ANIM_SCALE: i32 = 256;
31
32// ---------------------------------------------------------------------------
33// Tween
34// ---------------------------------------------------------------------------
35
36/// A pure, deterministic scalar tween: `from → to` over `duration` ticks.
37///
38/// Loop modes map onto the ticket vocabulary as: one-shot =
39/// [`LoopMode::Once`], repeat(N) = [`LoopMode::Repeat`]`(N)`, infinite =
40/// `Repeat(0)`, ping-pong = [`LoopMode::PingPong`] (`0` = infinite).
41///
42/// [`value_at`](Self::value_at) is a pure function of the tween's
43/// parameters and the tick argument; [`step`](Self::step) is the stateful
44/// equivalent advancing one tick at a time. Both observe identical
45/// sequences.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct Tween {
48    from: i32,
49    to: i32,
50    /// Duration of one cycle in ticks.
51    duration: u32,
52    /// Ticks elapsed since the tween started (stateful path only).
53    elapsed: u32,
54    easing: Easing,
55    loop_mode: LoopMode,
56}
57
58impl Tween {
59    /// Create a tween from `from` to `to` over `duration` ticks
60    /// (linear easing, one-shot).
61    ///
62    /// A `duration` of `0` is born finished at the `to` value.
63    pub fn new(from: i32, to: i32, duration: u32) -> Self {
64        Self {
65            from,
66            to,
67            duration,
68            elapsed: 0,
69            easing: Easing::Linear,
70            loop_mode: LoopMode::Once,
71        }
72    }
73
74    /// Set the easing curve (builder pattern).
75    pub fn with_easing(mut self, easing: Easing) -> Self {
76        self.easing = easing;
77        self
78    }
79
80    /// Set the loop mode (builder pattern).
81    pub fn with_loop(mut self, loop_mode: LoopMode) -> Self {
82        self.loop_mode = loop_mode;
83        self
84    }
85
86    /// Sample the tween at an absolute tick offset from its start.
87    ///
88    /// Pure: depends only on the tween's parameters and `tick`. Does not
89    /// advance state.
90    pub fn value_at(&self, tick: u32) -> i32 {
91        let (raw_t, _) = loop_progress(tick, self.duration, self.loop_mode);
92        let t = self.easing.apply(raw_t);
93        (self.from as f32 + (self.to as f32 - self.from as f32) * t) as i32
94    }
95
96    /// Advance the tween by exactly one tick and return the new value.
97    ///
98    /// Equivalent to `value_at(elapsed + 1)`; `elapsed` saturates at
99    /// `u32::MAX`.
100    pub fn step(&mut self) -> i32 {
101        self.elapsed = self.elapsed.saturating_add(1);
102        self.value_at(self.elapsed)
103    }
104
105    /// Sample the tween at its current elapsed tick count.
106    pub fn value(&self) -> i32 {
107        self.value_at(self.elapsed)
108    }
109
110    /// Ticks elapsed on the stateful path.
111    pub fn elapsed(&self) -> u32 {
112        self.elapsed
113    }
114
115    /// Returns `true` once the tween has completed.
116    ///
117    /// Infinite modes (`Repeat(0)`, `PingPong(0)`) never finish.
118    pub fn finished(&self) -> bool {
119        let (_, done) = loop_progress(self.elapsed, self.duration, self.loop_mode);
120        done
121    }
122}
123
124// ---------------------------------------------------------------------------
125// Animations registry
126// ---------------------------------------------------------------------------
127
128/// Opaque handle to a registered animation, returned by
129/// [`Animations::register`]. Used to [`cancel`](Animations::cancel).
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct AnimId(u32);
132
133/// Apply callback: writes the sampled value into its target and returns
134/// the screen region invalidated by that write (`None` = nothing visible
135/// changed).
136pub type ApplyFn = Box<dyn FnMut(i32) -> Option<Rect>>;
137
138struct Entry {
139    id: AnimId,
140    tween: Tween,
141    apply: ApplyFn,
142}
143
144/// Registry/scheduler for running [`Tween`]s bound to apply callbacks.
145///
146/// Call [`tick`](Self::tick) once per [`Event::Tick`]
147/// (`crate::event::Event::Tick`); it advances every entry by exactly one
148/// tick, applies the sampled values, accumulates the dirty rects the
149/// callbacks report (at most one per active animation per tick), and
150/// auto-removes completed entries *after* their terminal value has been
151/// applied — so a slide's final frame lands exactly on its rest position.
152#[derive(Default)]
153pub struct Animations {
154    entries: Vec<Entry>,
155    next_id: u32,
156    /// Dirty rects reported during the most recent [`tick`](Self::tick).
157    dirty: Vec<Rect>,
158}
159
160impl Animations {
161    /// Create an empty registry.
162    pub fn new() -> Self {
163        Self {
164            entries: Vec::new(),
165            next_id: 0,
166            dirty: Vec::new(),
167        }
168    }
169
170    /// Register a tween bound to an apply callback. Returns the handle
171    /// for later [`cancel`](Self::cancel).
172    pub fn register(&mut self, tween: Tween, apply: ApplyFn) -> AnimId {
173        let id = AnimId(self.next_id);
174        self.next_id = self.next_id.wrapping_add(1);
175        self.entries.push(Entry { id, tween, apply });
176        id
177    }
178
179    /// Advance all animations by one tick, apply their values, and drop
180    /// completed entries. Returns `true` while anything remains active.
181    ///
182    /// The dirty rects reported by the apply callbacks during this tick
183    /// replace those of the previous tick — read them via
184    /// [`dirty_rects`](Self::dirty_rects) or
185    /// [`dirty_union`](Self::dirty_union) before the next call.
186    pub fn tick(&mut self) -> bool {
187        self.dirty.clear();
188        for entry in &mut self.entries {
189            let value = entry.tween.step();
190            if let Some(rect) = (entry.apply)(value) {
191                self.dirty.push(rect);
192            }
193        }
194        self.entries.retain(|entry| !entry.tween.finished());
195        !self.entries.is_empty()
196    }
197
198    /// Returns `true` while any animation is registered (a repaint is
199    /// pending). Does not advance state.
200    pub fn any_active(&self) -> bool {
201        !self.entries.is_empty()
202    }
203
204    /// Number of registered animations.
205    pub fn len(&self) -> usize {
206        self.entries.len()
207    }
208
209    /// Returns `true` when no animations are registered.
210    pub fn is_empty(&self) -> bool {
211        self.entries.is_empty()
212    }
213
214    /// Dirty rects reported during the most recent [`tick`](Self::tick),
215    /// one per animation whose callback returned a region. ANIM does not
216    /// merge or clip — feed these to the consumer's planner.
217    pub fn dirty_rects(&self) -> &[Rect] {
218        &self.dirty
219    }
220
221    /// Axis-aligned union of [`dirty_rects`](Self::dirty_rects), or
222    /// `None` when the last tick changed nothing visible.
223    pub fn dirty_union(&self) -> Option<Rect> {
224        let mut union: Option<Rect> = None;
225        for &rect in &self.dirty {
226            union = Some(match union {
227                None => rect,
228                Some(u) => u.union(rect),
229            });
230        }
231        union
232    }
233
234    /// Remove an animation without applying a final value. Returns
235    /// `true` if the handle was registered.
236    pub fn cancel(&mut self, id: AnimId) -> bool {
237        let before = self.entries.len();
238        self.entries.retain(|entry| entry.id != id);
239        self.entries.len() != before
240    }
241
242    // -----------------------------------------------------------------
243    // Convenience adapters (driving cases)
244    // -----------------------------------------------------------------
245
246    /// Attention pulse: ping-pong a color between `from` and `to` with
247    /// `half_period` ticks per direction, forever (until cancelled).
248    ///
249    /// `apply` receives the interpolated color each tick and returns the
250    /// invalidated region (typically the pulsing widget's bounds).
251    pub fn pulse_color(
252        &mut self,
253        from: Color,
254        to: Color,
255        half_period: u32,
256        easing: Easing,
257        mut apply: Box<dyn FnMut(Color) -> Option<Rect>>,
258    ) -> AnimId {
259        let tween = Tween::new(0, ANIM_SCALE, half_period)
260            .with_easing(easing)
261            .with_loop(LoopMode::PingPong(0));
262        self.register(
263            tween,
264            Box::new(move |v| apply(from.lerp(to, v, ANIM_SCALE))),
265        )
266    }
267
268    /// Position slide: move a rect from `from` to `to` over `duration`
269    /// ticks, one-shot (show/hide of a container).
270    ///
271    /// `apply` receives the interpolated rect each tick and returns the
272    /// invalidated region. The recommended dirty region is
273    /// `previous_bounds.union(new_bounds)` so both the vacated and the
274    /// newly covered pixels repaint.
275    pub fn slide_rect(
276        &mut self,
277        from: Rect,
278        to: Rect,
279        duration: u32,
280        easing: Easing,
281        mut apply: Box<dyn FnMut(Rect) -> Option<Rect>>,
282    ) -> AnimId {
283        let tween = Tween::new(0, ANIM_SCALE, duration)
284            .with_easing(easing)
285            .with_loop(LoopMode::Once);
286        self.register(
287            tween,
288            Box::new(move |v| apply(from.lerp(to, v, ANIM_SCALE))),
289        )
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use alloc::rc::Rc;
297    use alloc::vec;
298    use core::cell::RefCell;
299
300    /// Frozen per-tick value tables (determinism oracle, ANIM-00 §12).
301    /// Regenerating these requires an ANIM-00 §15 amendment — they pin
302    /// the sample math against regressions.
303    #[test]
304    fn value_tables_linear_and_easeout_once() {
305        let linear = Tween::new(0, 100, 10);
306        let expected = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 100];
307        for (tick, want) in expected.iter().enumerate() {
308            assert_eq!(linear.value_at(tick as u32), *want, "linear tick {tick}");
309        }
310
311        let easeout = Tween::new(0, 100, 10).with_easing(Easing::EaseOut);
312        let expected = [0, 19, 35, 51, 64, 75, 84, 91, 96, 99, 100, 100];
313        for (tick, want) in expected.iter().enumerate() {
314            assert_eq!(easeout.value_at(tick as u32), *want, "easeout tick {tick}");
315        }
316
317        let negative = Tween::new(-120, 20, 8).with_easing(Easing::EaseOut);
318        let expected = [-120, -87, -58, -34, -15, 0, 11, 17, 20, 20];
319        for (tick, want) in expected.iter().enumerate() {
320            assert_eq!(
321                negative.value_at(tick as u32),
322                *want,
323                "negative tick {tick}"
324            );
325        }
326    }
327
328    #[test]
329    fn value_tables_pingpong_infinite() {
330        let linear = Tween::new(0, 256, 4).with_loop(LoopMode::PingPong(0));
331        let expected = [
332            0, 64, 128, 192, 256, 192, 128, 64, 0, 64, 128, 192, 256, 192, 128, 64, 0, 64,
333        ];
334        for (tick, want) in expected.iter().enumerate() {
335            assert_eq!(linear.value_at(tick as u32), *want, "pp linear tick {tick}");
336        }
337
338        let easeout = Tween::new(0, 256, 4)
339            .with_easing(Easing::EaseOut)
340            .with_loop(LoopMode::PingPong(0));
341        let expected = [
342            0, 112, 192, 240, 256, 240, 192, 112, 0, 112, 192, 240, 256, 240, 192, 112, 0, 112,
343        ];
344        for (tick, want) in expected.iter().enumerate() {
345            assert_eq!(
346                easeout.value_at(tick as u32),
347                *want,
348                "pp easeout tick {tick}"
349            );
350        }
351    }
352
353    #[test]
354    fn value_tables_repeat() {
355        let infinite = Tween::new(0, 100, 5).with_loop(LoopMode::Repeat(0));
356        let expected = [0, 20, 40, 60, 80, 0, 20, 40, 60, 80, 0, 20, 40];
357        for (tick, want) in expected.iter().enumerate() {
358            assert_eq!(infinite.value_at(tick as u32), *want, "rep inf tick {tick}");
359        }
360
361        let twice = Tween::new(0, 100, 5).with_loop(LoopMode::Repeat(2));
362        let expected = [0, 20, 40, 60, 80, 0, 20, 40, 60, 80, 100, 100, 100];
363        for (tick, want) in expected.iter().enumerate() {
364            assert_eq!(twice.value_at(tick as u32), *want, "rep 2 tick {tick}");
365        }
366    }
367
368    #[test]
369    fn step_matches_value_at_across_loop_cycles() {
370        let pure = Tween::new(-50, 999, 7)
371            .with_easing(Easing::EaseOut)
372            .with_loop(LoopMode::PingPong(0));
373        let mut stateful = pure;
374        for tick in 1..100u32 {
375            assert_eq!(stateful.step(), pure.value_at(tick), "tick {tick}");
376        }
377    }
378
379    #[test]
380    fn sequences_are_identical_across_runs() {
381        let sample = || -> Vec<i32> {
382            let tween = Tween::new(3, 977, 13)
383                .with_easing(Easing::EaseOut)
384                .with_loop(LoopMode::PingPong(0));
385            (0..200).map(|t| tween.value_at(t)).collect()
386        };
387        assert_eq!(sample(), sample());
388    }
389
390    #[test]
391    fn finished_semantics() {
392        let mut once = Tween::new(0, 10, 3);
393        assert!(!once.finished());
394        once.step();
395        once.step();
396        assert!(!once.finished());
397        once.step();
398        assert!(once.finished());
399
400        let mut twice = Tween::new(0, 10, 3).with_loop(LoopMode::Repeat(2));
401        for _ in 0..5 {
402            twice.step();
403        }
404        assert!(!twice.finished());
405        twice.step();
406        assert!(twice.finished());
407
408        let mut infinite = Tween::new(0, 10, 3).with_loop(LoopMode::Repeat(0));
409        let mut pingpong = Tween::new(0, 10, 3).with_loop(LoopMode::PingPong(0));
410        for _ in 0..10_000 {
411            infinite.step();
412            pingpong.step();
413        }
414        assert!(!infinite.finished());
415        assert!(!pingpong.finished());
416    }
417
418    #[test]
419    fn zero_duration_is_born_finished_at_end_value() {
420        let tween = Tween::new(7, 42, 0);
421        assert!(tween.finished());
422        assert_eq!(tween.value_at(0), 42);
423        assert_eq!(tween.value(), 42);
424    }
425
426    #[test]
427    fn registry_applies_terminal_value_then_removes() {
428        let seen = Rc::new(RefCell::new(Vec::new()));
429        let mut anims = Animations::new();
430        let sink = seen.clone();
431        anims.register(
432            Tween::new(0, 100, 2),
433            Box::new(move |v| {
434                sink.borrow_mut().push(v);
435                None
436            }),
437        );
438        assert!(anims.any_active());
439        assert!(anims.tick());
440        assert!(!anims.tick(), "completed entry retained");
441        assert!(!anims.any_active());
442        assert_eq!(*seen.borrow(), vec![50, 100], "terminal value applied");
443        assert!(!anims.tick(), "empty registry stays inactive");
444        assert_eq!(seen.borrow().len(), 2, "no application after removal");
445    }
446
447    #[test]
448    fn registry_cancel() {
449        let mut anims = Animations::new();
450        let id_a = anims.register(Tween::new(0, 1, 10), Box::new(|_| None));
451        let id_b = anims.register(Tween::new(0, 1, 10), Box::new(|_| None));
452        assert_eq!(anims.len(), 2);
453        assert!(anims.cancel(id_a));
454        assert!(!anims.cancel(id_a), "double-cancel reports false");
455        assert_eq!(anims.len(), 1);
456        assert!(anims.cancel(id_b));
457        assert!(anims.is_empty());
458    }
459
460    #[test]
461    fn twenty_five_concurrent_pulses_stay_within_dirty_budget() {
462        // ANIM-00 §12: ~25 concurrent pulses — one dirty rect each per
463        // tick, union excludes the rest of the screen.
464        let mut anims = Animations::new();
465        for i in 0..25i32 {
466            let rect = Rect {
467                x: 10 + (i % 5) * 40,
468                y: 10 + (i / 5) * 40,
469                width: 20,
470                height: 20,
471            };
472            anims.pulse_color(
473                Color(255, 255, 255, 255),
474                Color(40, 40, 40, 255),
475                32,
476                Easing::EaseOut,
477                Box::new(move |_| Some(rect)),
478            );
479        }
480        for _ in 0..100 {
481            assert!(anims.tick(), "infinite pulses stay active");
482            assert_eq!(anims.dirty_rects().len(), 25, "one rect per pulse");
483        }
484        let union = anims.dirty_union().expect("dirty union");
485        assert_eq!(
486            union,
487            Rect {
488                x: 10,
489                y: 10,
490                width: 180,
491                height: 180
492            },
493            "union bounded by the pulsing widgets, not the full frame"
494        );
495    }
496
497    #[test]
498    fn pulse_color_hits_endpoints_at_half_period_multiples() {
499        let last = Rc::new(RefCell::new(Color(0, 0, 0, 0)));
500        let mut anims = Animations::new();
501        let sink = last.clone();
502        let from = Color(200, 100, 50, 255);
503        let to = Color(20, 40, 60, 255);
504        anims.pulse_color(
505            from,
506            to,
507            8,
508            Easing::Linear,
509            Box::new(move |c| {
510                *sink.borrow_mut() = c;
511                None
512            }),
513        );
514        for tick in 1..=32u32 {
515            anims.tick();
516            match tick % 16 {
517                8 => assert_eq!(*last.borrow(), to, "peak at tick {tick}"),
518                0 => assert_eq!(*last.borrow(), from, "trough at tick {tick}"),
519                _ => {}
520            }
521        }
522    }
523
524    #[test]
525    fn slide_rect_lands_exactly_on_rest_position() {
526        let last = Rc::new(RefCell::new(None));
527        let mut anims = Animations::new();
528        let sink = last.clone();
529        let from = Rect {
530            x: -200,
531            y: 30,
532            width: 180,
533            height: 120,
534        };
535        let to = Rect {
536            x: 16,
537            y: 30,
538            width: 180,
539            height: 120,
540        };
541        anims.slide_rect(
542            from,
543            to,
544            24,
545            Easing::EaseOut,
546            Box::new(move |rect| {
547                let prev: Option<Rect> = sink.borrow_mut().replace(rect);
548                Some(prev.map_or(rect, |p| p.union(rect)))
549            }),
550        );
551        let mut ticks = 0;
552        while anims.tick() {
553            ticks += 1;
554            assert!(ticks <= 24, "slide overran its duration");
555        }
556        assert_eq!(ticks, 23, "auto-removed on the terminal tick");
557        assert_eq!(last.borrow().unwrap(), to, "final frame at rest position");
558        // The terminal tick's dirty rect covers the last hop.
559        assert!(anims.dirty_union().is_some());
560        assert!(anims.is_empty());
561    }
562}