Skip to main content

frus_core/animation/
controller.rs

1//! The driver: an [`AnimationController`] that samples a [`Simulation`] frame by
2//! frame and exposes a **current value** and a **status**.
3//!
4//! Everything the controller does — a curve-shaped transition over a duration, or
5//! a physical fling — is expressed as a `Simulation`, a pure `x(t)` function. One
6//! tick loop therefore drives all of it: *one driver, pluggable time→value
7//! functions*. This is the abstraction the shell instantiates by identity
8//! (`child_id`); the view reads `value()` at paint time.
9
10use super::curve::Curve;
11use super::simulation::{Simulation, SpringDescription, SpringSimulation, Tolerance};
12
13/// How far along an animation controller is.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Status {
16    /// At the lower bound, at rest.
17    Dismissed,
18    /// Moving towards the upper bound.
19    Forward,
20    /// Moving towards the lower bound.
21    Reverse,
22    /// At the upper bound, at rest.
23    Completed,
24}
25
26/// A "curve interpolated over a duration" simulation: the *explicit* side
27/// (duration plus curve), expressed as a `Simulation` like everything else.
28#[derive(Clone, Debug)]
29struct InterpolationSimulation {
30    begin: f32,
31    end: f32,
32    duration: f32,
33    curve: Curve,
34}
35
36impl Simulation for InterpolationSimulation {
37    fn x(&self, time: f32) -> f32 {
38        if self.duration <= 0.0 {
39            return self.end;
40        }
41        let t = (time / self.duration).clamp(0.0, 1.0);
42        let shaped = self.curve.transform(t);
43        self.begin + (self.end - self.begin) * shaped
44    }
45
46    fn dx(&self, time: f32) -> f32 {
47        let h = 1e-3;
48        (self.x(time + h) - self.x(time - h)) / (2.0 * h)
49    }
50
51    fn is_done(&self, time: f32) -> bool {
52        time >= self.duration
53    }
54}
55
56/// **Repeat** mode: each cycle lasts `period` seconds, shaped by `curve`;
57/// `reverse` makes it go there and back, otherwise it restarts from the bottom.
58#[derive(Clone)]
59struct Repeat {
60    period: f32,
61    reverse: bool,
62    curve: Curve,
63}
64
65/// Drives a value bounded to `[lower, upper]` through a pluggable simulation.
66pub struct AnimationController {
67    value: f32,
68    velocity: f32,
69    lower: f32,
70    upper: f32,
71    status: Status,
72    /// The active simulation plus time elapsed since it started; `None` at rest.
73    active: Option<(Box<dyn Simulation>, f32)>,
74    /// The intended direction, which sorts the resting status (`Completed` vs
75    /// `Dismissed`).
76    heading_up: bool,
77    /// The active repeat loop; `None` means a one-shot animation.
78    repeat: Option<Repeat>,
79}
80
81impl AnimationController {
82    /// A controller bounding its value to `[lower, upper]`, starting at `lower`.
83    pub fn new(lower: f32, upper: f32) -> Self {
84        Self {
85            value: lower,
86            velocity: 0.0,
87            lower,
88            upper,
89            status: Status::Dismissed,
90            active: None,
91            heading_up: false,
92            repeat: None,
93        }
94    }
95
96    /// The standard controller over `[0, 1]`.
97    pub fn unit() -> Self {
98        Self::new(0.0, 1.0)
99    }
100
101    /// The controller's `[lower, upper]` bounds.
102    pub fn bounds(&self) -> (f32, f32) {
103        (self.lower, self.upper)
104    }
105
106    /// The current value.
107    pub fn value(&self) -> f32 {
108        self.value
109    }
110
111    /// The current velocity, in units per second.
112    pub fn velocity(&self) -> f32 {
113        self.velocity
114    }
115
116    /// The current status.
117    pub fn status(&self) -> Status {
118        self.status
119    }
120
121    /// `true` while a simulation is running, meaning a frame is needed.
122    pub fn is_animating(&self) -> bool {
123        self.active.is_some()
124    }
125
126    /// Sets the value immediately, with no animation, and updates the status.
127    pub fn set_value(&mut self, value: f32) {
128        self.value = value.clamp(self.lower, self.upper);
129        self.velocity = 0.0;
130        self.active = None;
131        self.repeat = None;
132        self.status = self.rest_status();
133    }
134
135    /// Puts the value back to the lower bound, unanimated (`set_value(lower)`).
136    pub fn reset(&mut self) {
137        self.set_value(self.lower);
138    }
139
140    /// Stops any animation, a [`AnimationController::repeat`] loop included, and
141    /// freezes the current value.
142    pub fn stop(&mut self) {
143        self.active = None;
144        self.repeat = None;
145        self.velocity = 0.0;
146        self.status = self.rest_status();
147    }
148
149    /// Animates to the upper bound over `duration` seconds, along `curve`.
150    pub fn forward(&mut self, duration: f32, curve: Curve) {
151        self.animate_to(self.upper, duration, curve);
152    }
153
154    /// Animates to the lower bound over `duration` seconds, along `curve`.
155    pub fn reverse(&mut self, duration: f32, curve: Curve) {
156        self.animate_to(self.lower, duration, curve);
157    }
158
159    /// Animates the current value towards `target` over `duration` seconds. This
160    /// cancels any `repeat` loop, making it a one-shot animation.
161    pub fn animate_to(&mut self, target: f32, duration: f32, curve: Curve) {
162        self.repeat = None;
163        self.start_interpolation(target, duration, curve);
164    }
165
166    /// Starts an interpolation towards `target` **without** touching `repeat` mode
167    /// — shared by `animate_to` and by the loop restart inside `tick`.
168    fn start_interpolation(&mut self, target: f32, duration: f32, curve: Curve) {
169        let target = target.clamp(self.lower, self.upper);
170        self.heading_up = target >= self.value;
171        self.status = if self.heading_up {
172            Status::Forward
173        } else {
174            Status::Reverse
175        };
176        self.active = Some((
177            Box::new(InterpolationSimulation {
178                begin: self.value,
179                end: target,
180                duration,
181                curve,
182            }),
183            0.0,
184        ));
185    }
186
187    /// **Repeats** the animation in a loop, each cycle lasting `period` seconds and
188    /// shaped by `curve`. With `reverse` it goes there and back (`0→1→0…`);
189    /// otherwise each cycle restarts from the bottom (`0→1, 0→1…`). It only ends at
190    /// [`stop`](Self::stop), or `set_value` / `animate_to`. This is the idiom for
191    /// continuous driven animations: a pulse, a busy indicator, a halo.
192    pub fn repeat(&mut self, period: f32, reverse: bool, curve: Curve) {
193        self.repeat = Some(Repeat {
194            period,
195            reverse,
196            curve: curve.clone(),
197        });
198        // Start the first cycle upwards from the current value.
199        self.start_interpolation(self.upper, period, curve);
200    }
201
202    /// Launches a physical fling: a critical spring started by `velocity`, settling
203    /// on the position, bounded to `[lower, upper]`.
204    pub fn fling(&mut self, velocity: f32) {
205        self.repeat = None;
206        // A critically damped spring by default.
207        let spring = SpringDescription::with_damping_ratio(1.0, 500.0, 1.0);
208        let target = if velocity < 0.0 {
209            self.lower
210        } else {
211            self.upper
212        };
213        self.heading_up = velocity >= 0.0;
214        self.status = if self.heading_up {
215            Status::Forward
216        } else {
217            Status::Reverse
218        };
219        self.active = Some((
220            Box::new(SpringSimulation::new(
221                spring,
222                self.value,
223                target,
224                velocity,
225                Tolerance::default(),
226            )),
227            0.0,
228        ));
229    }
230
231    /// Animates the current value towards `target` with a **spring** described by
232    /// `spring`, started at `velocity` (units/s). This is the path taken by
233    /// interruptible, gesture-driven transitions — a drag release, a back gesture
234    /// carried by the finger's momentum: the spring starts from the current position
235    /// *and* the current velocity.
236    pub fn spring_to(&mut self, target: f32, spring: SpringDescription, velocity: f32) {
237        let target = target.clamp(self.lower, self.upper);
238        let heading_up = target >= self.value;
239        let sim = SpringSimulation::new(spring, self.value, target, velocity, Tolerance::default());
240        self.drive(Box::new(sim), heading_up);
241    }
242
243    /// Drives an arbitrary simulation — scroll momentum, a bespoke curve.
244    pub fn drive(&mut self, simulation: Box<dyn Simulation>, heading_up: bool) {
245        self.repeat = None;
246        self.heading_up = heading_up;
247        self.status = if heading_up {
248            Status::Forward
249        } else {
250            Status::Reverse
251        };
252        self.active = Some((simulation, 0.0));
253    }
254
255    /// The resting status matching the current value.
256    fn rest_status(&self) -> Status {
257        if (self.value - self.upper).abs() < 1e-4 {
258            Status::Completed
259        } else if (self.value - self.lower).abs() < 1e-4 {
260            Status::Dismissed
261        } else if self.heading_up {
262            Status::Completed
263        } else {
264            Status::Dismissed
265        }
266    }
267
268    /// Advances the animation by `dt` seconds, updating value, velocity and status.
269    /// Returns `true` while the animation continues, meaning another frame is due.
270    pub fn tick(&mut self, dt: f32) -> bool {
271        let Some((sim, elapsed)) = self.active.as_mut() else {
272            return false;
273        };
274        *elapsed += dt;
275        let time = *elapsed;
276        let raw = sim.x(time);
277        let velocity = sim.dx(time);
278        let done = sim.is_done(time);
279        // The borrow of `self.active` ends here; `sim` and `elapsed` are done with.
280        self.value = raw.clamp(self.lower, self.upper);
281        self.velocity = velocity;
282
283        // The end: either the simulation finished, or the value left the bounds — a
284        // fling hitting an edge stops dead, with no bounce beyond it.
285        let out_of_bounds = raw <= self.lower - 1e-4 || raw >= self.upper + 1e-4;
286        if done || out_of_bounds {
287            // A loop is active: restart a cycle instead of coming to rest.
288            if let Some(rep) = self.repeat.clone() {
289                let target = if rep.reverse {
290                    // There and back: head for the edge opposite the one reached.
291                    if self.heading_up {
292                        self.lower
293                    } else {
294                        self.upper
295                    }
296                } else {
297                    // Sawtooth: start again from the bottom, heading up.
298                    self.value = self.lower;
299                    self.upper
300                };
301                self.start_interpolation(target, rep.period, rep.curve);
302                return true;
303            }
304            self.velocity = 0.0;
305            self.active = None;
306            self.status = self.rest_status();
307            false
308        } else {
309            true
310        }
311    }
312}
313
314impl Default for AnimationController {
315    /// A standard controller over `[0, 1]`, at rest at `0`.
316    fn default() -> Self {
317        Self::unit()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    /// Advances the controller to rest, or to `max_frames`, in 16 ms steps.
326    fn settle(ctrl: &mut AnimationController, max_frames: usize) -> usize {
327        let mut frames = 0;
328        while ctrl.tick(0.016) {
329            frames += 1;
330            if frames >= max_frames {
331                break;
332            }
333        }
334        frames
335    }
336
337    #[test]
338    fn forward_reaches_upper_and_completes() {
339        let mut ctrl = AnimationController::unit();
340        assert_eq!(ctrl.status(), Status::Dismissed);
341        ctrl.forward(0.3, Curve::ease_in_out());
342        assert_eq!(ctrl.status(), Status::Forward);
343        assert!(ctrl.is_animating());
344        settle(&mut ctrl, 1000);
345        assert!(
346            (ctrl.value() - 1.0).abs() < 1e-3,
347            "value = {}",
348            ctrl.value()
349        );
350        assert_eq!(ctrl.status(), Status::Completed);
351        assert!(!ctrl.is_animating());
352    }
353
354    #[test]
355    fn reverse_reaches_lower_and_dismisses() {
356        let mut ctrl = AnimationController::unit();
357        ctrl.set_value(1.0);
358        assert_eq!(ctrl.status(), Status::Completed);
359        ctrl.reverse(0.3, Curve::ease_in_out());
360        assert_eq!(ctrl.status(), Status::Reverse);
361        settle(&mut ctrl, 1000);
362        assert!(ctrl.value().abs() < 1e-3);
363        assert_eq!(ctrl.status(), Status::Dismissed);
364    }
365
366    #[test]
367    fn value_is_monotonic_during_forward() {
368        let mut ctrl = AnimationController::unit();
369        ctrl.forward(0.5, Curve::ease_in_out());
370        let mut prev = ctrl.value();
371        while ctrl.tick(0.016) {
372            assert!(
373                ctrl.value() >= prev - 1e-4,
374                "recule : {} < {}",
375                ctrl.value(),
376                prev
377            );
378            assert!(ctrl.value() <= 1.0 + 1e-4);
379            prev = ctrl.value();
380        }
381    }
382
383    #[test]
384    fn fling_upward_settles_at_upper() {
385        let mut ctrl = AnimationController::unit();
386        ctrl.set_value(0.5);
387        ctrl.fling(3.0); // a positive velocity heads up
388        assert_eq!(ctrl.status(), Status::Forward);
389        settle(&mut ctrl, 2000);
390        assert!(
391            (ctrl.value() - 1.0).abs() < 1e-2,
392            "value = {}",
393            ctrl.value()
394        );
395        assert_eq!(ctrl.status(), Status::Completed);
396    }
397
398    #[test]
399    fn fling_downward_settles_at_lower() {
400        let mut ctrl = AnimationController::unit();
401        ctrl.set_value(0.5);
402        ctrl.fling(-3.0);
403        settle(&mut ctrl, 2000);
404        assert!(ctrl.value().abs() < 1e-2, "value = {}", ctrl.value());
405        assert_eq!(ctrl.status(), Status::Dismissed);
406    }
407
408    #[test]
409    fn spring_to_settles_at_target_from_velocity() {
410        let mut ctrl = AnimationController::unit();
411        ctrl.set_value(0.2);
412        // A flick-driven release: a near-critical spring towards the upper bound.
413        let spring = SpringDescription::new(1.0, 220.0, 30.0);
414        ctrl.spring_to(1.0, spring, 5.0);
415        assert_eq!(ctrl.status(), Status::Forward);
416        settle(&mut ctrl, 2000);
417        assert!(
418            (ctrl.value() - 1.0).abs() < 1e-2,
419            "value = {}",
420            ctrl.value()
421        );
422        assert_eq!(ctrl.status(), Status::Completed);
423    }
424
425    /// `repeat` (sawtooth): never rests, reaches the top, then **drops back** as the
426    /// cycle restarts.
427    #[test]
428    fn repeat_never_settles_and_restarts() {
429        let mut ctrl = AnimationController::unit();
430        ctrl.repeat(0.1, false, Curve::Linear);
431        let (mut max, mut saw_restart, mut prev) = (0.0f32, false, ctrl.value());
432        for _ in 0..100 {
433            // about 1.6 s = 16 cycles
434            ctrl.tick(0.016);
435            assert!(ctrl.is_animating(), "a looping controller always animates");
436            assert!(ctrl.value() >= -1e-4 && ctrl.value() <= 1.0 + 1e-4);
437            max = max.max(ctrl.value());
438            if ctrl.value() + 1e-3 < prev {
439                saw_restart = true; // the value dropped, so a new cycle began
440            }
441            prev = ctrl.value();
442        }
443        assert!(max > 0.9, "reaches the top (max = {max})");
444        assert!(saw_restart, "it restarts: the value drops back");
445    }
446
447    /// `repeat(reverse)`: there and back — the value rises, then falls again.
448    #[test]
449    fn repeat_reverse_ping_pongs() {
450        let mut ctrl = AnimationController::unit();
451        ctrl.repeat(0.1, true, Curve::Linear);
452        let (mut went_up, mut came_down, mut prev) = (false, false, ctrl.value());
453        for _ in 0..40 {
454            ctrl.tick(0.016);
455            if ctrl.value() > prev + 1e-4 {
456                went_up = true;
457            }
458            if went_up && ctrl.value() < prev - 1e-4 {
459                came_down = true;
460            }
461            prev = ctrl.value();
462        }
463        assert!(went_up && came_down, "a round trip: up then down");
464        assert!(ctrl.is_animating());
465    }
466
467    /// `stop` ends a loop; `reset` brings it back to the bottom.
468    #[test]
469    fn stop_and_reset_end_a_repeat() {
470        let mut ctrl = AnimationController::unit();
471        ctrl.repeat(0.1, false, Curve::Linear);
472        ctrl.tick(0.05);
473        assert!(ctrl.is_animating());
474        ctrl.stop();
475        assert!(!ctrl.is_animating(), "stop halts the loop");
476        assert!(!ctrl.tick(0.016), "nothing left to animate after stop");
477
478        ctrl.repeat(0.1, false, Curve::Linear);
479        ctrl.tick(0.05);
480        ctrl.reset();
481        assert!(!ctrl.is_animating(), "reset halts the loop");
482        assert_eq!(ctrl.value(), 0.0, "reset returns to the lower bound");
483    }
484
485    #[test]
486    fn zero_duration_snaps_immediately() {
487        let mut ctrl = AnimationController::unit();
488        ctrl.forward(0.0, Curve::Linear);
489        // A single frame is enough to reach the target.
490        ctrl.tick(0.016);
491        assert!((ctrl.value() - 1.0).abs() < 1e-4);
492        assert!(!ctrl.is_animating());
493    }
494}