Skip to main content

frust_core/
anim.rs

1//! Animation vocabulary: the shared frame clock ([`FrameTime`]) plus the pure
2//! math widgets consume it through — easing [`Curve`]s, [`Tween`] interpolation,
3//! analytic [`Spring`] dynamics, and the duration-/spring-driven
4//! [`AnimationController`].
5//!
6//! # Contract: this module is plain data + math, never a scheduler
7//!
8//! Nothing here reads a clock, spawns a task, or registers a callback.
9//! [`FrameTime`] *enters from the shell* (no `Instant::now()` inside
10//! `frust-core`) and is threaded to widgets during paint as
11//! [`crate::widget::PaintCtx::frame_time`]. A widget that animates does so
12//! **during its own paint**: it calls [`AnimationController::advance`] with the
13//! frame's time, reads [`AnimationController::value`], and — if `advance`
14//! returned `true` (still animating) — calls
15//! [`crate::widget::PaintCtx::request_frame`] so the shell schedules another
16//! frame. There is no ambient tick; a controller that is never `advance`d never
17//! moves.
18
19use std::time::Duration;
20
21use kurbo::{Point, Rect, Size};
22use peniko::Color;
23
24/// A point in time supplied by the shell, in monotonic nanoseconds.
25///
26/// The origin is **arbitrary and shell-specific** (desktop: nanos since a
27/// shell-owned epoch; Android: a `Choreographer` frame nanos value; iOS: a
28/// `CADisplayLink` timestamp). Widgets may therefore only *difference* two
29/// `FrameTime`s (via [`FrameTime::saturating_sub`]) — never interpret one
30/// absolutely, and never compare `FrameTime`s produced by different shells.
31///
32/// [`FrameTime::ZERO`] is the documented "no time available" fallback used by
33/// legacy/test paint paths that don't thread a real clock.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct FrameTime(u64);
36
37impl FrameTime {
38    /// The zero instant — also the "no time available" fallback (see the type
39    /// docs). Differencing against it yields the raw nanosecond value.
40    pub const ZERO: FrameTime = FrameTime(0);
41
42    /// Build a `FrameTime` from a raw monotonic-nanosecond count.
43    pub const fn from_nanos(nanos: u64) -> Self {
44        FrameTime(nanos)
45    }
46
47    /// The raw monotonic-nanosecond count. Meaningful only relative to another
48    /// `FrameTime` from the same shell (see the type docs).
49    pub const fn as_nanos(self) -> u64 {
50        self.0
51    }
52
53    /// This instant as fractional seconds from the (arbitrary) origin. Only
54    /// differences of two such values carry meaning.
55    pub fn as_secs_f64(self) -> f64 {
56        self.0 as f64 / 1_000_000_000.0
57    }
58
59    /// The non-negative [`Duration`] elapsed from `earlier` to `self`.
60    ///
61    /// Saturates at zero when `self < earlier` (a non-monotonic or reordered
62    /// clock), so a widget differencing two frame times can never observe a
63    /// negative delta — the basis of [`AnimationController::advance`]'s
64    /// clamp-and-never-panic guarantee.
65    pub fn saturating_sub(self, earlier: FrameTime) -> Duration {
66        Duration::from_nanos(self.0.saturating_sub(earlier.0))
67    }
68}
69
70/// An easing curve mapping a normalized time `t ∈ [0, 1]` to an eased progress
71/// `∈ [0, 1]`.
72///
73/// The named variants are the standard CSS timing functions plus Material 3's
74/// `Emphasized` easing; [`Curve::Cubic`] is a general cubic-Bézier for anything
75/// else. Evaluation clamps `t` to `[0, 1]` first.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub enum Curve {
78    /// `t` unchanged.
79    Linear,
80    /// CSS `ease-in` — `cubic-bezier(0.42, 0, 1, 1)`.
81    EaseIn,
82    /// CSS `ease-out` — `cubic-bezier(0, 0, 0.58, 1)`.
83    EaseOut,
84    /// CSS `ease-in-out` — `cubic-bezier(0.42, 0, 0.58, 1)`.
85    EaseInOut,
86    /// Material 3 emphasized easing — `cubic-bezier(0.2, 0.0, 0.0, 1.0)`.
87    Emphasized,
88    /// A general cubic-Bézier timing function with control points
89    /// `(x1, y1)` and `(x2, y2)` (endpoints fixed at `(0, 0)`/`(1, 1)`), the
90    /// same parameterization CSS `cubic-bezier()` uses.
91    Cubic(f64, f64, f64, f64),
92}
93
94impl Curve {
95    /// Evaluate the eased progress at normalized time `t` (clamped to `[0, 1]`).
96    pub fn transform(self, t: f64) -> f64 {
97        let t = t.clamp(0.0, 1.0);
98        match self {
99            Curve::Linear => t,
100            Curve::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t),
101            Curve::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t),
102            Curve::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t),
103            Curve::Emphasized => cubic_bezier(0.2, 0.0, 0.0, 1.0, t),
104            Curve::Cubic(x1, y1, x2, y2) => cubic_bezier(x1, y1, x2, y2, t),
105        }
106    }
107
108    /// Confine this curve to the `[start, end]` fraction of the timeline
109    /// (Flutter's `Interval`, TweenSequence-style curve segmentation):
110    /// before `start` the result is `0.0`, at/after `end` it's `1.0`, and in
111    /// between it re-evaluates `self` at the rescaled local time
112    /// `(t - start) / (end - start)`.
113    ///
114    /// This is what lets two widgets fade through each other on one shared
115    /// controller — e.g. an outgoing element on `curve.interval(0.0, 0.3)`
116    /// and an incoming one on `curve.interval(0.3, 1.0)`.
117    ///
118    /// `start`/`end` aren't required to be `[0, 1]`-clamped or ordered here;
119    /// a degenerate or inverted interval (`end <= start`) is defined as an
120    /// instantaneous step at `start`: strictly before it the result is
121    /// `0.0`, at or after it's `1.0` (see [`SegmentedCurve::transform`]).
122    ///
123    /// Returns a [`SegmentedCurve`] rather than `Curve` itself: `Curve` is
124    /// `Copy` (relied on by [`AnimationController`], which derives `Copy`
125    /// over its own `curve: Curve` field), and an `Interval` variant holding
126    /// another `Curve` by value would make the enum infinitely-sized without
127    /// indirection (`Box`), which would forfeit that `Copy` bound — so the
128    /// segment lives in its own small `Copy` struct instead.
129    pub fn interval(self, start: f64, end: f64) -> SegmentedCurve {
130        SegmentedCurve {
131            start,
132            end,
133            inner: self,
134        }
135    }
136}
137
138/// A [`Curve`] confined to a `[start, end]` sub-interval of the timeline —
139/// the result of [`Curve::interval`]. See that method's docs for the full
140/// contract (including the degenerate `end <= start` case).
141#[derive(Clone, Copy, Debug, PartialEq)]
142pub struct SegmentedCurve {
143    start: f64,
144    end: f64,
145    inner: Curve,
146}
147
148impl SegmentedCurve {
149    /// Evaluate the eased progress at normalized time `t` (clamped to
150    /// `[0, 1]`) — see [`Curve::interval`] for the full contract.
151    pub fn transform(self, t: f64) -> f64 {
152        let t = t.clamp(0.0, 1.0);
153        if self.end <= self.start {
154            // Degenerate/inverted interval: an instantaneous step at `start`.
155            return if t < self.start { 0.0 } else { 1.0 };
156        }
157        if t <= self.start {
158            0.0
159        } else if t >= self.end {
160            1.0
161        } else {
162            let local = (t - self.start) / (self.end - self.start);
163            self.inner.transform(local)
164        }
165    }
166}
167
168/// Evaluate a cubic-Bézier timing function at input `x ∈ [0, 1]`.
169///
170/// Control points are `(0, 0)`, `(x1, y1)`, `(x2, y2)`, `(1, 1)` — the CSS
171/// `cubic-bezier()` parameterization. The curve is defined parametrically in
172/// `s`; this solves `X(s) = x` for `s` (Newton's method, then a bisection
173/// fallback if Newton leaves the valid interval) and returns `Y(s)`.
174fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 {
175    // Polynomial coefficients of X(s) and Y(s) = ((a·s + b)·s + c)·s.
176    let cx = 3.0 * x1;
177    let bx = 3.0 * (x2 - x1) - cx;
178    let ax = 1.0 - cx - bx;
179    let cy = 3.0 * y1;
180    let by = 3.0 * (y2 - y1) - cy;
181    let ay = 1.0 - cy - by;
182
183    let sample_x = |s: f64| ((ax * s + bx) * s + cx) * s;
184    let sample_y = |s: f64| ((ay * s + by) * s + cy) * s;
185    let sample_dx = |s: f64| (3.0 * ax * s + 2.0 * bx) * s + cx;
186
187    // Newton–Raphson from x as the initial guess (X is close to identity for
188    // well-behaved timing functions).
189    let mut s = x;
190    for _ in 0..8 {
191        let err = sample_x(s) - x;
192        if err.abs() < 1e-9 {
193            return sample_y(s);
194        }
195        let d = sample_dx(s);
196        if d.abs() < 1e-9 {
197            break;
198        }
199        s -= err / d;
200    }
201
202    // Bisection fallback (guaranteed to converge on the monotone `X` a valid
203    // timing function has).
204    let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
205    let mut s = x.clamp(lo, hi);
206    for _ in 0..32 {
207        let cur = sample_x(s);
208        if (cur - x).abs() < 1e-9 {
209            break;
210        }
211        if cur < x {
212            lo = s;
213        } else {
214            hi = s;
215        }
216        s = 0.5 * (lo + hi);
217    }
218    sample_y(s)
219}
220
221/// Per-item timing for a staggered reveal (Flutter's staggered-animation
222/// idiom): item `i`'s sub-animation starts at `i * per_item_delay` and runs
223/// for `item_duration`, all driven off one shared `0.0..=1.0` controller
224/// value — no per-item controller needed.
225///
226/// `per_item_delay`/`item_duration` share whatever time unit the caller
227/// picks (seconds, milliseconds, …) as long as it's consistent between the
228/// two fields and [`total_duration`](Self::total_duration)'s consumer (e.g.
229/// seeding an [`AnimationController`]'s `Duration` in the same unit).
230#[derive(Clone, Copy, Debug, PartialEq)]
231pub struct StaggerSpec {
232    /// Time between the start of consecutive items' sub-animations.
233    pub per_item_delay: f64,
234    /// Duration of each item's own sub-animation.
235    pub item_duration: f64,
236    /// Easing curve applied within each item's local `[0, 1]` sub-progress.
237    pub item_curve: Curve,
238}
239
240impl StaggerSpec {
241    /// The total timeline length spanning all `n` items' sub-animations —
242    /// the last item starts at `(n - 1) * per_item_delay` and runs
243    /// `item_duration`, so the total is `(n - 1) * per_item_delay +
244    /// item_duration`. Use this to size an [`AnimationController`]'s
245    /// `forward` duration so one controller value drives every item.
246    /// `n == 0` has no timeline: `0.0`.
247    pub fn total_duration(&self, n: usize) -> f64 {
248        if n == 0 {
249            return 0.0;
250        }
251        (n as f64 - 1.0) * self.per_item_delay + self.item_duration
252    }
253
254    /// Item `i`'s eased progress in `[0, 1]`, given the overall controller
255    /// value `overall` (clamped to `[0, 1]`, linear across
256    /// [`total_duration`](Self::total_duration)`(n)`) and the total item
257    /// count `n`.
258    ///
259    /// Before item `i`'s local window starts, this is `0.0`; after it ends,
260    /// `1.0`; in between it re-evaluates `item_curve` at the item's local
261    /// `[0, 1]` sub-progress. A zero-length `item_duration` degenerates to
262    /// an instantaneous step at the item's start time, mirroring
263    /// [`Curve::interval`]'s degenerate case. `n == 0` (no timeline) and
264    /// `i >= n` both have no defined window, so this returns `1.0` (nothing
265    /// left to reveal).
266    pub fn item_progress(&self, overall: f64, i: usize, n: usize) -> f64 {
267        let total = self.total_duration(n);
268        if total <= 0.0 || i >= n {
269            return 1.0;
270        }
271        let overall = overall.clamp(0.0, 1.0);
272        let elapsed = overall * total;
273        let item_start = i as f64 * self.per_item_delay;
274        let item_end = item_start + self.item_duration;
275        if self.item_duration <= 0.0 {
276            return if elapsed < item_start { 0.0 } else { 1.0 };
277        }
278        if elapsed <= item_start {
279            0.0
280        } else if elapsed >= item_end {
281            1.0
282        } else {
283            let local = (elapsed - item_start) / self.item_duration;
284            self.item_curve.transform(local)
285        }
286    }
287}
288
289/// Component-wise linear interpolation between two values of the same type.
290///
291/// Implemented for the value types animations move: `f64`, `peniko::Color`
292/// (component lerp in sRGB — an acceptable v1 approximation, not a
293/// perceptual/linear-light blend), and the `kurbo` geometry types
294/// [`Point`]/[`Size`]/[`Rect`].
295pub trait Lerp {
296    /// Interpolate from `self` (at `t = 0`) to `other` (at `t = 1`).
297    fn lerp(&self, other: &Self, t: f64) -> Self;
298}
299
300impl Lerp for f64 {
301    fn lerp(&self, other: &Self, t: f64) -> Self {
302        self + (other - self) * t
303    }
304}
305
306impl Lerp for Point {
307    fn lerp(&self, other: &Self, t: f64) -> Self {
308        Point::new(self.x.lerp(&other.x, t), self.y.lerp(&other.y, t))
309    }
310}
311
312impl Lerp for Size {
313    fn lerp(&self, other: &Self, t: f64) -> Self {
314        Size::new(
315            self.width.lerp(&other.width, t),
316            self.height.lerp(&other.height, t),
317        )
318    }
319}
320
321impl Lerp for Rect {
322    fn lerp(&self, other: &Self, t: f64) -> Self {
323        Rect::new(
324            self.x0.lerp(&other.x0, t),
325            self.y0.lerp(&other.y0, t),
326            self.x1.lerp(&other.x1, t),
327            self.y1.lerp(&other.y1, t),
328        )
329    }
330}
331
332impl Lerp for Color {
333    /// Straight (non-premultiplied) per-component lerp in the sRGB encoding.
334    ///
335    /// This blends the encoded sRGB components directly, which is cheap and
336    /// visually acceptable for v1 but is *not* a linear-light or perceptual
337    /// interpolation — a future revision may move to linear-light blending.
338    fn lerp(&self, other: &Self, t: f64) -> Self {
339        let a = self.components;
340        let b = other.components;
341        let t = t as f32;
342        Color::new([
343            a[0] + (b[0] - a[0]) * t,
344            a[1] + (b[1] - a[1]) * t,
345            a[2] + (b[2] - a[2]) * t,
346            a[3] + (b[3] - a[3]) * t,
347        ])
348    }
349}
350
351/// A `begin`→`end` interpolation over a [`Lerp`] value type.
352#[derive(Clone, Copy, Debug, PartialEq)]
353pub struct Tween<T> {
354    /// The value at `t = 0`.
355    pub begin: T,
356    /// The value at `t = 1`.
357    pub end: T,
358}
359
360impl<T: Lerp> Tween<T> {
361    /// Construct a tween between `begin` and `end`.
362    pub fn new(begin: T, end: T) -> Self {
363        Tween { begin, end }
364    }
365
366    /// The interpolated value at `t` (typically the eased progress from a
367    /// [`Curve`] or an [`AnimationController::value`]). `t` is not clamped here;
368    /// clamp upstream (a [`Curve`] already does) if extrapolation is unwanted.
369    pub fn lerp(&self, t: f64) -> T {
370        self.begin.lerp(&self.end, t)
371    }
372}
373
374/// Physical parameters of a damped spring (a mass on a spring with a damper).
375///
376/// `damping_ratio` is the dimensionless ζ: `< 1` under-damped (overshoots and
377/// oscillates), `== 1` critically damped (fastest non-overshooting settle),
378/// `> 1` over-damped (slow, no overshoot). M3's standard spring presets (which
379/// live in `frust-theme`, not here) are expressed in these terms.
380#[derive(Clone, Copy, Debug, PartialEq)]
381pub struct SpringDesc {
382    /// Mass of the moving body (`> 0`).
383    pub mass: f64,
384    /// Spring stiffness `k` (`> 0`); higher is snappier.
385    pub stiffness: f64,
386    /// Damping ratio ζ (`> 0`); see the type docs.
387    pub damping_ratio: f64,
388}
389
390/// The analytic response of a [`SpringDesc`] released from an initial
391/// displacement + velocity toward equilibrium at `0`.
392///
393/// Positions returned by [`Spring::position`] are **displacements from
394/// equilibrium** (the target), so equilibrium is always `0`. The closed-form
395/// solution branches on the damping regime; all three are exact (no numerical
396/// integration), which is what lets a fling be evaluated at an arbitrary frame
397/// time without accumulating step error.
398#[derive(Clone, Copy, Debug)]
399pub struct Spring {
400    kind: SpringKind,
401}
402
403#[derive(Clone, Copy, Debug)]
404enum SpringKind {
405    /// ζ < 1: decaying oscillation.
406    Under {
407        w0: f64,
408        wd: f64,
409        zeta: f64,
410        a: f64,
411        b: f64,
412    },
413    /// ζ ≈ 1: `(a + b·t)·e^{-w0·t}`.
414    Critical { w0: f64, a: f64, b: f64 },
415    /// ζ > 1: sum of two decaying exponentials.
416    Over { r1: f64, r2: f64, c1: f64, c2: f64 },
417}
418
419impl Spring {
420    /// Solve the spring released from displacement `x0` (relative to
421    /// equilibrium) with initial velocity `v0`.
422    ///
423    /// Degenerate parameters (non-positive mass/stiffness) fall back to a
424    /// critically-damped unit spring so the constructor never produces `NaN`.
425    pub fn new(desc: SpringDesc, x0: f64, v0: f64) -> Self {
426        let mass = if desc.mass > 0.0 { desc.mass } else { 1.0 };
427        let stiffness = if desc.stiffness > 0.0 {
428            desc.stiffness
429        } else {
430            1.0
431        };
432        let zeta = desc.damping_ratio.max(0.0);
433        let w0 = (stiffness / mass).sqrt();
434
435        let kind = if (zeta - 1.0).abs() < 1e-6 {
436            // Critically damped.
437            let a = x0;
438            let b = v0 + w0 * x0;
439            SpringKind::Critical { w0, a, b }
440        } else if zeta < 1.0 {
441            // Under-damped.
442            let wd = w0 * (1.0 - zeta * zeta).sqrt();
443            let a = x0;
444            let b = (v0 + zeta * w0 * x0) / wd;
445            SpringKind::Under { w0, wd, zeta, a, b }
446        } else {
447            // Over-damped.
448            let s = (zeta * zeta - 1.0).sqrt();
449            let r1 = -w0 * (zeta - s);
450            let r2 = -w0 * (zeta + s);
451            let c1 = (v0 - r2 * x0) / (r1 - r2);
452            let c2 = x0 - c1;
453            SpringKind::Over { r1, r2, c1, c2 }
454        };
455        Spring { kind }
456    }
457
458    /// The displacement from equilibrium at elapsed time `t` (seconds).
459    pub fn position(&self, t: f64) -> f64 {
460        match self.kind {
461            SpringKind::Under {
462                w0, wd, zeta, a, b, ..
463            } => {
464                let e = (-zeta * w0 * t).exp();
465                e * (a * (wd * t).cos() + b * (wd * t).sin())
466            }
467            SpringKind::Critical { w0, a, b } => {
468                let e = (-w0 * t).exp();
469                e * (a + b * t)
470            }
471            SpringKind::Over { r1, r2, c1, c2 } => c1 * (r1 * t).exp() + c2 * (r2 * t).exp(),
472        }
473    }
474
475    /// The velocity (d displacement / d t) at elapsed time `t` (seconds).
476    pub fn velocity(&self, t: f64) -> f64 {
477        match self.kind {
478            SpringKind::Under {
479                w0, wd, zeta, a, b, ..
480            } => {
481                let e = (-zeta * w0 * t).exp();
482                let c = (wd * t).cos();
483                let s = (wd * t).sin();
484                e * ((b * wd - zeta * w0 * a) * c - (a * wd + zeta * w0 * b) * s)
485            }
486            SpringKind::Critical { w0, a, b } => {
487                let e = (-w0 * t).exp();
488                e * (b - w0 * (a + b * t))
489            }
490            SpringKind::Over { r1, r2, c1, c2 } => {
491                r1 * c1 * (r1 * t).exp() + r2 * c2 * (r2 * t).exp()
492            }
493        }
494    }
495
496    /// Whether the spring has effectively settled at equilibrium by time `t`:
497    /// both `|position|` and `|velocity|` are below `epsilon`.
498    pub fn is_at_rest(&self, t: f64, epsilon: f64) -> bool {
499        self.position(t).abs() < epsilon && self.velocity(t).abs() < epsilon
500    }
501}
502
503/// The lifecycle status of an [`AnimationController`].
504#[derive(Clone, Copy, Debug, PartialEq, Eq)]
505pub enum AnimationStatus {
506    /// Not started, or stopped via [`AnimationController::stop`].
507    Idle,
508    /// Animating toward a higher value (forward).
509    Forward,
510    /// Animating toward a lower value (reverse).
511    Reverse,
512    /// Reached its forward target at rest.
513    Completed,
514    /// Reached its reverse target at rest.
515    Dismissed,
516}
517
518/// The rest threshold (in value- and velocity-units) a spring fling must fall
519/// below before the controller snaps to its target and reports `Completed`/
520/// `Dismissed`.
521const SPRING_REST_EPSILON: f64 = 1e-3;
522
523/// A `0.0..=1.0` animation value driven either by a duration + [`Curve`] or by
524/// a [`Spring`] fling.
525///
526/// It is **plain data + math** (see the module docs' contract): it holds no
527/// clock and no scheduler. A widget advances it during paint with the frame's
528/// [`FrameTime`], reads [`value`](Self::value), and re-requests a frame while
529/// [`advance`](Self::advance) keeps returning `true`.
530///
531/// # Status semantics
532///
533/// [`forward`](Self::forward)/[`reverse`](Self::reverse) drive to the `1.0`/
534/// `0.0` bounds and settle as `Completed`/`Dismissed`. [`animate_to`](Self::animate_to)
535/// settles as `Completed` when it moved forward (target ≥ start) and `Dismissed`
536/// when it moved reverse. [`repeat`](Self::repeat) never completes ([`advance`](Self::advance)
537/// always returns `true`). A [`fling`](Self::fling) settles at `1.0` (positive
538/// velocity) or `0.0` (negative) as `Completed`/`Dismissed`.
539///
540/// # Overshoot (spring-driven only)
541///
542/// Duration+[`Curve`] motion ([`forward`]/[`reverse`]/[`animate_to`]/[`repeat`])
543/// always keeps [`value`](Self::value) in `0.0..=1.0` — unchanged from before
544/// this contract existed. A [`fling`](Self::fling), by contrast, is **not**
545/// clamped while in flight: an under-damped [`SpringDesc`] (M3's spatial
546/// presets use `damping_ratio: 0.9`) genuinely overshoots its target before
547/// settling, and that overshoot is the whole visual point of a "bouncy"
548/// spring — clamping it away would hide it. `value()` may therefore transiently
549/// read outside `[0, 1]` mid-fling; it always lands exactly on the target
550/// (`0.0`/`1.0`) once [`advance`](Self::advance) reports settled (`status()`
551/// becomes `Completed`/`Dismissed`). A consumer that needs the value
552/// pinned to `[0, 1]` at every frame (e.g. to feed a `Lerp`/`Tween` that
553/// assumes bounded input) should use [`value_clamped`](Self::value_clamped)
554/// instead. A critically-/over-damped spring (`damping_ratio >= 1.0`, e.g.
555/// M3's "effects" presets) released with zero velocity (the common
556/// "settle to target" fling usage) never overshoots in the first place, so
557/// `value()`/`value_clamped()` agree for that case; a large enough release
558/// velocity can still carry even a critically-/over-damped spring past its
559/// target before it settles back — damping ratio bounds *oscillation*
560/// (repeated overshoot), not a single one.
561///
562/// [`forward`]: Self::forward
563/// [`reverse`]: Self::reverse
564/// [`animate_to`]: Self::animate_to
565/// [`repeat`]: Self::repeat
566#[derive(Clone, Copy, Debug)]
567pub struct AnimationController {
568    value: f64,
569    duration: Duration,
570    curve: Curve,
571    status: AnimationStatus,
572    drive: Drive,
573    last_time: Option<FrameTime>,
574}
575
576/// The active motion an [`AnimationController`] is running, if any.
577#[derive(Clone, Copy, Debug)]
578enum Drive {
579    /// Not animating.
580    Idle,
581    /// Duration-driven interpolation from `start` to `target` over `duration`
582    /// seconds, `elapsed` so far, eased by the controller's [`Curve`].
583    Duration {
584        start: f64,
585        target: f64,
586        elapsed: f64,
587        duration: f64,
588    },
589    /// Looping `0.0→1.0` (eased) with period `period` seconds.
590    Repeat { elapsed: f64, period: f64 },
591    /// Spring-driven fling toward `target`, `elapsed` seconds in.
592    Fling {
593        spring: Spring,
594        target: f64,
595        elapsed: f64,
596    },
597}
598
599impl AnimationController {
600    /// Create an idle controller at value `0.0` with the given default duration
601    /// and a [`Curve::Linear`] easing.
602    pub fn new(duration: Duration) -> Self {
603        AnimationController {
604            value: 0.0,
605            duration,
606            curve: Curve::Linear,
607            status: AnimationStatus::Idle,
608            drive: Drive::Idle,
609            last_time: None,
610        }
611    }
612
613    /// Set the easing curve applied to duration-driven motion (returns `self`
614    /// for builder-style construction).
615    pub fn with_curve(mut self, curve: Curve) -> Self {
616        self.curve = curve;
617        self
618    }
619
620    /// The current value.
621    ///
622    /// For duration+[`Curve`] motion this is always in `0.0..=1.0`. For a
623    /// spring [`fling`](Self::fling) it may transiently read outside that
624    /// range — an under-damped spring's overshoot is real motion, not a bug
625    /// (see the type docs' Overshoot section) — but always lands exactly on
626    /// the target once the fling settles. Use
627    /// [`value_clamped`](Self::value_clamped) if a bounded `[0, 1]` read is
628    /// required instead.
629    pub fn value(&self) -> f64 {
630        self.value
631    }
632
633    /// [`value`](Self::value), clamped to `0.0..=1.0`.
634    ///
635    /// Identical to `value()` for duration+[`Curve`] motion (already
636    /// bounded); for a spring [`fling`](Self::fling) mid-overshoot this
637    /// clips the transient excursion past the target — for consumers (e.g. a
638    /// `Lerp`/`Tween` feed) that need a bounded value and don't want the
639    /// bounce visually represented.
640    pub fn value_clamped(&self) -> f64 {
641        self.value.clamp(0.0, 1.0)
642    }
643
644    /// The current lifecycle status.
645    pub fn status(&self) -> AnimationStatus {
646        self.status
647    }
648
649    /// Whether a motion is currently in progress (the next [`advance`](Self::advance)
650    /// will make progress).
651    pub fn is_animating(&self) -> bool {
652        !matches!(self.drive, Drive::Idle)
653    }
654
655    /// Animate forward to `1.0` over the controller's duration.
656    pub fn forward(&mut self) {
657        self.start_duration(self.value, 1.0, AnimationStatus::Forward);
658    }
659
660    /// Animate reverse to `0.0` over the controller's duration.
661    pub fn reverse(&mut self) {
662        self.start_duration(self.value, 0.0, AnimationStatus::Reverse);
663    }
664
665    /// Animate from the current value to `target` (clamped to `0.0..=1.0`) over
666    /// the controller's duration.
667    pub fn animate_to(&mut self, target: f64) {
668        let target = target.clamp(0.0, 1.0);
669        let status = if target >= self.value {
670            AnimationStatus::Forward
671        } else {
672            AnimationStatus::Reverse
673        };
674        self.start_duration(self.value, target, status);
675    }
676
677    /// Loop `0.0→1.0` (eased) indefinitely with a period of the controller's
678    /// duration. [`advance`](Self::advance) always returns `true` for a repeat.
679    pub fn repeat(&mut self) {
680        let period = self.duration.as_secs_f64();
681        self.value = 0.0;
682        self.status = AnimationStatus::Forward;
683        self.last_time = None;
684        self.drive = if period > 0.0 {
685            Drive::Repeat {
686                elapsed: 0.0,
687                period,
688            }
689        } else {
690            // A zero-period repeat can't advance; degrade to idle.
691            Drive::Idle
692        };
693    }
694
695    /// Start a spring fling from the current value with initial `velocity` (in
696    /// value-units per second). It settles toward `1.0` for a non-negative
697    /// velocity, `0.0` otherwise.
698    ///
699    /// Unlike duration+[`Curve`] motion, the value driven by a fling is
700    /// **not clamped to `[0, 1]` while in flight** — see the type docs'
701    /// Overshoot section and [`value`](Self::value)/
702    /// [`value_clamped`](Self::value_clamped).
703    pub fn fling(&mut self, velocity: f64, spring: SpringDesc) {
704        let target = if velocity >= 0.0 { 1.0 } else { 0.0 };
705        let x0 = self.value - target;
706        self.status = if target >= self.value {
707            AnimationStatus::Forward
708        } else {
709            AnimationStatus::Reverse
710        };
711        self.last_time = None;
712        self.drive = Drive::Fling {
713            spring: Spring::new(spring, x0, velocity),
714            target,
715            elapsed: 0.0,
716        };
717    }
718
719    /// Halt any in-progress motion, leaving the value where it is and the status
720    /// [`AnimationStatus::Idle`].
721    pub fn stop(&mut self) {
722        self.drive = Drive::Idle;
723        self.status = AnimationStatus::Idle;
724        self.last_time = None;
725    }
726
727    /// Advance the animation to frame time `now`, returning whether it is still
728    /// animating (in which case the caller must request another frame).
729    ///
730    /// The delta from the previous `advance` is derived via
731    /// [`FrameTime::saturating_sub`], so a repeated or out-of-order timestamp
732    /// yields a zero (never negative) delta: safe, no panic, no `NaN`. The first
733    /// `advance` after starting a motion only seeds the clock (zero delta); the
734    /// next one makes progress.
735    pub fn advance(&mut self, now: FrameTime) -> bool {
736        let dt = match self.last_time {
737            Some(last) => now.saturating_sub(last).as_secs_f64(),
738            None => 0.0,
739        };
740        self.last_time = Some(now);
741        // `saturating_sub` already guarantees `dt >= 0`; guard against a
742        // non-finite value defensively so a downstream `value` is never `NaN`.
743        let dt = if dt.is_finite() { dt.max(0.0) } else { 0.0 };
744
745        match &mut self.drive {
746            Drive::Idle => false,
747            Drive::Duration {
748                start,
749                target,
750                elapsed,
751                duration,
752            } => {
753                let (start, target, duration) = (*start, *target, *duration);
754                *elapsed += dt;
755                let elapsed = *elapsed;
756                if duration <= 0.0 || elapsed >= duration {
757                    self.value = target;
758                    self.status = if target >= start {
759                        AnimationStatus::Completed
760                    } else {
761                        AnimationStatus::Dismissed
762                    };
763                    self.drive = Drive::Idle;
764                    false
765                } else {
766                    let frac = (elapsed / duration).clamp(0.0, 1.0);
767                    let eased = self.curve.transform(frac);
768                    self.value = start + (target - start) * eased;
769                    true
770                }
771            }
772            Drive::Repeat { elapsed, period } => {
773                let period = *period;
774                *elapsed += dt;
775                let frac = if period > 0.0 {
776                    (*elapsed / period).rem_euclid(1.0)
777                } else {
778                    0.0
779                };
780                self.value = self.curve.transform(frac);
781                true
782            }
783            Drive::Fling {
784                spring,
785                target,
786                elapsed,
787            } => {
788                let spring = *spring;
789                let target = *target;
790                *elapsed += dt;
791                let elapsed = *elapsed;
792                if spring.is_at_rest(elapsed, SPRING_REST_EPSILON) {
793                    self.value = target;
794                    self.status = if target >= 0.5 {
795                        AnimationStatus::Completed
796                    } else {
797                        AnimationStatus::Dismissed
798                    };
799                    self.drive = Drive::Idle;
800                    false
801                } else {
802                    // Deliberately unclamped: an under-damped spring's
803                    // overshoot past the target is real, intended motion
804                    // (see the type docs' Overshoot section), and clamping
805                    // it here would hide it. `value_clamped()` is the
806                    // bounded-read escape hatch for consumers that need one.
807                    self.value = target + spring.position(elapsed);
808                    true
809                }
810            }
811        }
812    }
813
814    /// Shared entry for the duration-driven motions.
815    fn start_duration(&mut self, start: f64, target: f64, status: AnimationStatus) {
816        let duration = self.duration.as_secs_f64();
817        self.status = status;
818        self.last_time = None;
819        if duration <= 0.0 || (target - start).abs() < f64::EPSILON {
820            // Nothing to animate: snap and settle immediately.
821            self.value = target;
822            self.status = if target >= start {
823                AnimationStatus::Completed
824            } else {
825                AnimationStatus::Dismissed
826            };
827            self.drive = Drive::Idle;
828        } else {
829            self.drive = Drive::Duration {
830                start,
831                target,
832                elapsed: 0.0,
833                duration,
834            };
835        }
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    fn ft_secs(s: f64) -> FrameTime {
844        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
845    }
846
847    #[test]
848    fn frame_time_differences_only() {
849        let a = FrameTime::from_nanos(1_000);
850        let b = FrameTime::from_nanos(3_500);
851        assert_eq!(b.saturating_sub(a), Duration::from_nanos(2_500));
852        // Non-monotonic: saturates at zero.
853        assert_eq!(a.saturating_sub(b), Duration::ZERO);
854        assert!((ft_secs(2.0).as_secs_f64() - 2.0).abs() < 1e-9);
855    }
856
857    #[test]
858    fn ease_in_out_matches_css_reference_at_half() {
859        // ease-in-out is symmetric → its midpoint is exactly 0.5.
860        assert!((Curve::EaseInOut.transform(0.5) - 0.5).abs() < 1e-4);
861        // Endpoints are pinned.
862        assert!((Curve::EaseInOut.transform(0.0)).abs() < 1e-9);
863        assert!((Curve::EaseInOut.transform(1.0) - 1.0).abs() < 1e-9);
864        // ease-in starts slow (below linear at the midpoint); ease-out ends slow
865        // (above linear). Symmetric pair: ease-in(x) + ease-out(1-x) == 1.
866        assert!(Curve::EaseIn.transform(0.5) < 0.5);
867        assert!(Curve::EaseOut.transform(0.5) > 0.5);
868        assert!((Curve::EaseIn.transform(0.5) + Curve::EaseOut.transform(0.5) - 1.0).abs() < 1e-4);
869    }
870
871    #[test]
872    fn linear_curve_is_identity() {
873        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
874            assert!((Curve::Linear.transform(t) - t).abs() < 1e-12);
875        }
876        // Out-of-range inputs clamp.
877        assert_eq!(Curve::Linear.transform(-1.0), 0.0);
878        assert_eq!(Curve::Linear.transform(2.0), 1.0);
879    }
880
881    #[test]
882    fn interval_endpoints_and_midpoint() {
883        let seg = Curve::EaseInOut.interval(0.3, 0.7);
884        // Pre-start.
885        assert_eq!(seg.transform(0.0), 0.0);
886        assert_eq!(seg.transform(0.3), 0.0);
887        assert_eq!(seg.transform(0.1), 0.0);
888        // Post-end.
889        assert_eq!(seg.transform(0.7), 1.0);
890        assert_eq!(seg.transform(0.9), 1.0);
891        assert_eq!(seg.transform(1.0), 1.0);
892        // Midpoint of the interval == midpoint of the inner curve (ease-in-out
893        // is symmetric, so its own midpoint is exactly 0.5).
894        let local_mid = 0.3 + 0.5 * (0.7 - 0.3);
895        assert!((seg.transform(local_mid) - 0.5).abs() < 1e-4);
896        // A quarter into the interval matches the inner curve at local t=0.25.
897        let local_quarter = 0.3 + 0.25 * (0.7 - 0.3);
898        assert!((seg.transform(local_quarter) - Curve::EaseInOut.transform(0.25)).abs() < 1e-9);
899    }
900
901    #[test]
902    fn interval_linear_matches_flutter_fade_through_shape() {
903        // Fade-through staging: outgoing on [0.0, 0.3], incoming on [0.3, 1.0].
904        let out_seg = Curve::Linear.interval(0.0, 0.3);
905        let in_seg = Curve::Linear.interval(0.3, 1.0);
906        assert_eq!(out_seg.transform(0.0), 0.0);
907        assert!((out_seg.transform(0.15) - 0.5).abs() < 1e-9);
908        assert_eq!(out_seg.transform(0.3), 1.0);
909        assert_eq!(in_seg.transform(0.3), 0.0);
910        assert!((in_seg.transform(0.65) - 0.5).abs() < 1e-9);
911        assert_eq!(in_seg.transform(1.0), 1.0);
912    }
913
914    #[test]
915    fn interval_degenerate_start_equals_end_is_an_instant_step() {
916        let seg = Curve::Linear.interval(0.3, 0.3);
917        assert_eq!(seg.transform(0.0), 0.0);
918        assert_eq!(seg.transform(0.2), 0.0);
919        // Just below the step point: still 0.0.
920        assert_eq!(seg.transform(0.3), 1.0);
921        assert_eq!(seg.transform(0.5), 1.0);
922        assert_eq!(seg.transform(1.0), 1.0);
923    }
924
925    #[test]
926    fn interval_degenerate_inverted_is_an_instant_step_at_start() {
927        // end < start also degrades to the same instant-step rule.
928        let seg = Curve::Linear.interval(0.6, 0.2);
929        assert_eq!(seg.transform(0.0), 0.0);
930        assert_eq!(seg.transform(0.59), 0.0);
931        assert_eq!(seg.transform(0.6), 1.0);
932        assert_eq!(seg.transform(1.0), 1.0);
933    }
934
935    #[test]
936    fn interval_out_of_range_t_clamps_first() {
937        let seg = Curve::Linear.interval(0.3, 0.7);
938        assert_eq!(seg.transform(-1.0), 0.0);
939        assert_eq!(seg.transform(2.0), 1.0);
940    }
941
942    /// Glyph log-reveal shape: 150ms items, 90ms per-item delay, 5 items —
943    /// hand-computed against `StaggerSpec::item_progress`'s contract.
944    fn glyph_stagger() -> StaggerSpec {
945        StaggerSpec {
946            per_item_delay: 90.0,
947            item_duration: 150.0,
948            item_curve: Curve::Linear,
949        }
950    }
951
952    #[test]
953    fn stagger_total_duration_spans_last_items_window() {
954        let spec = glyph_stagger();
955        // Item 4 (last of 5) starts at 4*90=360 and runs 150ms -> ends 510.
956        assert_eq!(spec.total_duration(5), 510.0);
957        assert_eq!(spec.total_duration(1), 150.0);
958        assert_eq!(spec.total_duration(0), 0.0);
959    }
960
961    #[test]
962    fn stagger_item_progress_hand_computed_table() {
963        let spec = glyph_stagger();
964        let n = 5;
965        let total = spec.total_duration(n); // 510.0
966
967        // Item windows (start..end): 0..150, 90..240, 180..330, 270..420, 360..510.
968        // overall=0.0 -> elapsed=0: only item 0 has started (at its own start -> 0.0).
969        for i in 0..n {
970            assert_eq!(spec.item_progress(0.0, i, n), 0.0, "i={i} at overall=0.0");
971        }
972
973        // overall=1.0 -> elapsed=510: every item has reached/passed its end -> 1.0.
974        for i in 0..n {
975            assert_eq!(spec.item_progress(1.0, i, n), 1.0, "i={i} at overall=1.0");
976        }
977
978        // elapsed = 270 (item 3's start; item 2's local 90/150=0.6; item 1 and
979        // item 0 already past their end -> 1.0; item 4 not started -> 0.0).
980        let overall_270 = 270.0 / total;
981        assert_eq!(spec.item_progress(overall_270, 0, n), 1.0);
982        assert_eq!(spec.item_progress(overall_270, 1, n), 1.0);
983        assert!((spec.item_progress(overall_270, 2, n) - 0.6).abs() < 1e-9);
984        assert_eq!(spec.item_progress(overall_270, 3, n), 0.0);
985        assert_eq!(spec.item_progress(overall_270, 4, n), 0.0);
986
987        // elapsed = 45ms: only item 0 has started, at local 45/150 = 0.3.
988        let overall_45 = 45.0 / total;
989        assert!((spec.item_progress(overall_45, 0, n) - 0.3).abs() < 1e-9);
990        assert_eq!(spec.item_progress(overall_45, 1, n), 0.0);
991
992        // elapsed = 135ms: item 0 local 135/150=0.9; item 1 not started (starts
993        // at 90, so local (135-90)/150=0.3).
994        let overall_135 = 135.0 / total;
995        assert!((spec.item_progress(overall_135, 0, n) - 0.9).abs() < 1e-9);
996        assert!((spec.item_progress(overall_135, 1, n) - 0.3).abs() < 1e-9);
997    }
998
999    #[test]
1000    fn stagger_item_progress_out_of_range_index_or_empty_set_is_complete() {
1001        let spec = glyph_stagger();
1002        // i >= n has no defined window -> fully revealed.
1003        assert_eq!(spec.item_progress(0.5, 5, 5), 1.0);
1004        // n == 0 has no timeline -> fully revealed.
1005        assert_eq!(spec.item_progress(0.5, 0, 0), 1.0);
1006    }
1007
1008    #[test]
1009    fn stagger_zero_duration_item_is_an_instant_step() {
1010        let spec = StaggerSpec {
1011            per_item_delay: 100.0,
1012            item_duration: 0.0,
1013            item_curve: Curve::Linear,
1014        };
1015        let n = 3;
1016        let total = spec.total_duration(n); // 2*100 + 0 = 200
1017        assert_eq!(total, 200.0);
1018        // Item 1 starts at 100: strictly before -> 0.0, at/after -> 1.0.
1019        assert_eq!(spec.item_progress(99.0 / total, 1, n), 0.0);
1020        assert_eq!(spec.item_progress(100.0 / total, 1, n), 1.0);
1021    }
1022
1023    #[test]
1024    fn tween_interpolates_value_types() {
1025        assert!((Tween::new(0.0_f64, 10.0).lerp(0.25) - 2.5).abs() < 1e-12);
1026        assert_eq!(
1027            Tween::new(Point::new(0.0, 0.0), Point::new(4.0, 8.0)).lerp(0.5),
1028            Point::new(2.0, 4.0)
1029        );
1030        assert_eq!(
1031            Tween::new(Size::new(0.0, 0.0), Size::new(10.0, 20.0)).lerp(0.1),
1032            Size::new(1.0, 2.0)
1033        );
1034        assert_eq!(
1035            Tween::new(Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(2.0, 2.0, 6.0, 6.0)).lerp(0.5),
1036            Rect::new(1.0, 1.0, 4.0, 4.0)
1037        );
1038        let c = Tween::new(Color::BLACK, Color::WHITE).lerp(0.5);
1039        for ch in &c.components[..3] {
1040            assert!((ch - 0.5).abs() < 1e-6);
1041        }
1042    }
1043
1044    /// RK4-integrate `m·x'' + c·x' + k·x = 0` to `t`, returning position.
1045    fn integrate_spring(desc: SpringDesc, x0: f64, v0: f64, t_end: f64) -> f64 {
1046        let m = desc.mass;
1047        let k = desc.stiffness;
1048        let c = 2.0 * desc.damping_ratio * (k * m).sqrt();
1049        let accel = |x: f64, v: f64| -(k * x + c * v) / m;
1050        let dt = 1e-5;
1051        let steps = (t_end / dt).round() as usize;
1052        let (mut x, mut v) = (x0, v0);
1053        for _ in 0..steps {
1054            let (k1x, k1v) = (v, accel(x, v));
1055            let (k2x, k2v) = (
1056                v + 0.5 * dt * k1v,
1057                accel(x + 0.5 * dt * k1x, v + 0.5 * dt * k1v),
1058            );
1059            let (k3x, k3v) = (
1060                v + 0.5 * dt * k2v,
1061                accel(x + 0.5 * dt * k2x, v + 0.5 * dt * k2v),
1062            );
1063            let (k4x, k4v) = (v + dt * k3v, accel(x + dt * k3x, v + dt * k3v));
1064            x += dt / 6.0 * (k1x + 2.0 * k2x + 2.0 * k3x + k4x);
1065            v += dt / 6.0 * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
1066        }
1067        x
1068    }
1069
1070    #[test]
1071    fn spring_analytic_matches_numeric_under_critical_over() {
1072        let cases = [
1073            // M3-flavored under-damped case: stiffness 700, damping 0.9.
1074            SpringDesc {
1075                mass: 1.0,
1076                stiffness: 700.0,
1077                damping_ratio: 0.9,
1078            },
1079            SpringDesc {
1080                mass: 1.0,
1081                stiffness: 700.0,
1082                damping_ratio: 1.0,
1083            },
1084            SpringDesc {
1085                mass: 1.0,
1086                stiffness: 700.0,
1087                damping_ratio: 1.5,
1088            },
1089        ];
1090        for desc in cases {
1091            let spring = Spring::new(desc, 1.0, 0.0);
1092            for &t in &[0.005, 0.01, 0.02, 0.03] {
1093                let analytic = spring.position(t);
1094                let numeric = integrate_spring(desc, 1.0, 0.0, t);
1095                assert!(
1096                    (analytic - numeric).abs() < 1e-6,
1097                    "regime ζ={} at t={t}: analytic {analytic} vs numeric {numeric}",
1098                    desc.damping_ratio
1099                );
1100            }
1101        }
1102    }
1103
1104    #[test]
1105    fn spring_velocity_matches_finite_difference() {
1106        let desc = SpringDesc {
1107            mass: 1.0,
1108            stiffness: 700.0,
1109            damping_ratio: 0.9,
1110        };
1111        let spring = Spring::new(desc, 1.0, 0.0);
1112        let t = 0.01;
1113        let h = 1e-7;
1114        let fd = (spring.position(t + h) - spring.position(t - h)) / (2.0 * h);
1115        assert!((spring.velocity(t) - fd).abs() < 1e-4);
1116    }
1117
1118    #[test]
1119    fn duration_forward_reaches_completed() {
1120        let mut c = AnimationController::new(Duration::from_millis(100));
1121        c.forward();
1122        assert_eq!(c.status(), AnimationStatus::Forward);
1123        // Seed the clock.
1124        assert!(c.advance(ft_secs(0.0)));
1125        assert!(c.advance(ft_secs(0.05)));
1126        assert!((c.value() - 0.5).abs() < 1e-6);
1127        // Past the end: settles Completed at 1.0, no longer animating.
1128        assert!(!c.advance(ft_secs(0.2)));
1129        assert_eq!(c.value(), 1.0);
1130        assert_eq!(c.status(), AnimationStatus::Completed);
1131    }
1132
1133    #[test]
1134    fn reverse_reaches_dismissed() {
1135        let mut c = AnimationController::new(Duration::from_millis(100));
1136        // Start from the top.
1137        c.forward();
1138        c.advance(ft_secs(0.0));
1139        c.advance(ft_secs(0.2));
1140        assert_eq!(c.value(), 1.0);
1141
1142        c.reverse();
1143        assert_eq!(c.status(), AnimationStatus::Reverse);
1144        c.advance(ft_secs(1.0));
1145        assert!(!c.advance(ft_secs(1.2)));
1146        assert_eq!(c.value(), 0.0);
1147        assert_eq!(c.status(), AnimationStatus::Dismissed);
1148    }
1149
1150    #[test]
1151    fn repeat_wraps_and_never_completes() {
1152        let mut c = AnimationController::new(Duration::from_secs(1));
1153        c.repeat();
1154        assert!(c.advance(ft_secs(0.0)));
1155        assert!(c.advance(ft_secs(0.5)));
1156        assert!((c.value() - 0.5).abs() < 1e-6);
1157        // Wrap past one period back toward 0.
1158        assert!(c.advance(ft_secs(1.5)));
1159        assert!((c.value() - 0.5).abs() < 1e-6);
1160        assert!(c.advance(ft_secs(2.0)));
1161        assert!(c.value() < 1e-6);
1162        // Always still animating.
1163        assert!(c.advance(ft_secs(100.0)));
1164    }
1165
1166    #[test]
1167    fn animate_to_partial_target() {
1168        let mut c = AnimationController::new(Duration::from_millis(100));
1169        c.animate_to(0.3);
1170        assert_eq!(c.status(), AnimationStatus::Forward);
1171        c.advance(ft_secs(0.0));
1172        assert!(!c.advance(ft_secs(0.2)));
1173        assert!((c.value() - 0.3).abs() < 1e-6);
1174        assert_eq!(c.status(), AnimationStatus::Completed);
1175    }
1176
1177    #[test]
1178    fn fling_settles_at_target() {
1179        let mut c = AnimationController::new(Duration::from_millis(100));
1180        c.fling(
1181            2.0,
1182            SpringDesc {
1183                mass: 1.0,
1184                stiffness: 700.0,
1185                damping_ratio: 0.9,
1186            },
1187        );
1188        let mut t = 0.0;
1189        let mut running = true;
1190        // Advance at ~120fps until settled (bounded so a bug can't hang the test).
1191        for _ in 0..100_000 {
1192            running = c.advance(ft_secs(t));
1193            if !running {
1194                break;
1195            }
1196            t += 1.0 / 120.0;
1197        }
1198        assert!(!running, "fling failed to settle");
1199        assert!((c.value() - 1.0).abs() < 1e-6);
1200        assert_eq!(c.status(), AnimationStatus::Completed);
1201    }
1202
1203    #[test]
1204    fn fling_overshoots_past_target_for_underdamped_spring() {
1205        // M3's default-spatial preset: ζ = 0.9, k = 700 — under-damped, so a
1206        // fling released with zero velocity still oscillates around the
1207        // target before settling.
1208        let desc = SpringDesc {
1209            mass: 1.0,
1210            stiffness: 700.0,
1211            damping_ratio: 0.9,
1212        };
1213        let mut c = AnimationController::new(Duration::from_millis(100));
1214        c.fling(0.0, desc);
1215
1216        // Track the max value observed while flying; the analytic
1217        // cross-check against `Spring` directly lives in the sibling test
1218        // `fling_overshoot_values_match_analytic_spring`.
1219        let mut max_value = f64::MIN;
1220        let mut t = 0.0;
1221        let mut running = true;
1222        for _ in 0..100_000 {
1223            running = c.advance(ft_secs(t));
1224            if !running {
1225                break;
1226            }
1227            max_value = max_value.max(c.value());
1228            t += 1.0 / 120.0;
1229        }
1230        assert!(!running, "fling failed to settle");
1231        assert!(
1232            max_value > 1.0 + 1e-3,
1233            "expected a demonstrable overshoot past 1.0, got max {max_value}"
1234        );
1235        assert_eq!(c.value(), 1.0);
1236        assert_eq!(c.status(), AnimationStatus::Completed);
1237    }
1238
1239    #[test]
1240    fn fling_overshoot_values_match_analytic_spring() {
1241        let desc = SpringDesc {
1242            mass: 1.0,
1243            stiffness: 700.0,
1244            damping_ratio: 0.9,
1245        };
1246        let mut c = AnimationController::new(Duration::from_millis(100));
1247        c.fling(0.0, desc);
1248        let spring = Spring::new(desc, -1.0, 0.0);
1249
1250        // Seed the clock, then check a handful of in-flight samples against
1251        // the analytic spring directly.
1252        assert!(c.advance(ft_secs(0.0)));
1253        for &t in &[0.01, 0.02, 0.03, 0.05, 0.08] {
1254            assert!(c.advance(ft_secs(t)));
1255            let expected = 1.0 + spring.position(t);
1256            assert!(
1257                (c.value() - expected).abs() < 1e-6,
1258                "at t={t}: controller {} vs analytic {expected}",
1259                c.value()
1260            );
1261        }
1262    }
1263
1264    #[test]
1265    fn effects_spring_never_exceeds_target() {
1266        // M3's default-effects preset: ζ = 1.0 — critically damped, released
1267        // from rest (velocity 0), so it approaches the target monotonically
1268        // with no overshoot.
1269        let desc = SpringDesc {
1270            mass: 1.0,
1271            stiffness: 1600.0,
1272            damping_ratio: 1.0,
1273        };
1274        let mut c = AnimationController::new(Duration::from_millis(100));
1275        c.fling(0.0, desc);
1276
1277        let mut t = 0.0;
1278        let mut running = true;
1279        for _ in 0..100_000 {
1280            running = c.advance(ft_secs(t));
1281            assert!(
1282                c.value() <= 1.0 + 1e-9,
1283                "critically damped spring released from rest overshot: value {} at t={t}",
1284                c.value()
1285            );
1286            if !running {
1287                break;
1288            }
1289            t += 1.0 / 120.0;
1290        }
1291        assert!(!running, "fling failed to settle");
1292        assert_eq!(c.value(), 1.0);
1293        assert_eq!(c.status(), AnimationStatus::Completed);
1294    }
1295
1296    #[test]
1297    fn value_clamped_bounds_an_overshooting_fling() {
1298        let desc = SpringDesc {
1299            mass: 1.0,
1300            stiffness: 700.0,
1301            damping_ratio: 0.9,
1302        };
1303        let mut c = AnimationController::new(Duration::from_millis(100));
1304        c.fling(0.0, desc);
1305        c.advance(ft_secs(0.0));
1306        c.advance(ft_secs(0.02));
1307        // Overshoot is expected in `value()` but `value_clamped()` must stay
1308        // bounded regardless.
1309        assert!(c.value_clamped() >= 0.0 && c.value_clamped() <= 1.0);
1310        assert_eq!(c.value_clamped(), c.value().clamp(0.0, 1.0));
1311    }
1312
1313    #[test]
1314    fn advance_is_safe_on_equal_and_backward_timestamps() {
1315        let mut c = AnimationController::new(Duration::from_millis(100));
1316        c.forward();
1317        c.advance(ft_secs(0.05));
1318        let v_seed = c.value();
1319        // Equal timestamp: zero delta, no progress, no panic/NaN.
1320        c.advance(ft_secs(0.05));
1321        assert_eq!(c.value(), v_seed);
1322        assert!(c.value().is_finite());
1323        // Backward timestamp: clamped to zero delta.
1324        c.advance(ft_secs(0.01));
1325        assert_eq!(c.value(), v_seed);
1326        assert!(c.value().is_finite());
1327    }
1328
1329    #[test]
1330    fn stop_halts_progress() {
1331        let mut c = AnimationController::new(Duration::from_millis(100));
1332        c.forward();
1333        c.advance(ft_secs(0.0));
1334        c.advance(ft_secs(0.05));
1335        let v = c.value();
1336        c.stop();
1337        assert_eq!(c.status(), AnimationStatus::Idle);
1338        assert!(!c.advance(ft_secs(0.5)));
1339        assert_eq!(c.value(), v);
1340    }
1341}