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}