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