Skip to main content

damascene_core/anim/
mod.rs

1//! Animation primitives.
2//!
3//! Two motion models ship: spring physics (semi-implicit Euler) and
4//! cubic-bezier tweens. Springs are the default — they continue from
5//! current+velocity when retargeted mid-flight, which is what makes
6//! interrupted motion feel right (mouse-out-mid-fade eases back from
7//! where it is, not from rest). Tweens cover the explicit-duration
8//! cases where the curve matters more than the physics.
9//!
10//! ## Animatable values
11//!
12//! [`AnimValue`] holds the per-prop state the integrator works on.
13//! `Float` (1 channel) covers opacity / scale / translation; `Color`
14//! (4 channels) covers fills / strokes / text colors. The integrator
15//! treats each channel as an independent 1-D mass-spring-damper.
16//!
17//! ## Spring config
18//!
19//! Mass-spring-damper: `m·a = -k·x - c·v` where `x = current - target`,
20//! integrated semi-implicitly. `dt` is clamped to 64 ms so a stalled
21//! frame can't blow up the integrator. Settles when both displacement
22//! and velocity drop below epsilon for *all* channels.
23//!
24//! ## Headless determinism
25//!
26//! The bundle path calls [`Animation::settle`] on every in-flight
27//! animation before snapshotting, so SVG/PNG fixtures are byte-identical
28//! run-to-run regardless of how many frames were sampled.
29
30// Lock in full per-item documentation for this module (issue #73).
31#![warn(missing_docs)]
32
33use std::time::Duration;
34// web_time::Instant works on wasm32 (std::time::Instant::now() panics there).
35use web_time::Instant;
36
37use crate::color::Oklab;
38use crate::tree::Color;
39
40pub mod tick;
41
42/// A value the animator can interpolate. Each variant fans out to a
43/// fixed number of f32 channels that the integrator steps independently.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub enum AnimValue {
46    /// A single f32 channel — opacity, scale, or a logical-pixel offset.
47    Float(f32),
48    /// A four-channel color, integrated in Oklab (see [`AnimValue::channels`]).
49    Color(Color),
50}
51
52impl AnimValue {
53    /// Per-variant `(displacement, velocity)` settle thresholds for the
54    /// spring integrator. Oklab-channeled colors live in a tighter
55    /// numeric range than pixel-offset floats, so they get tighter
56    /// epsilons.
57    pub fn settle_thresholds(self) -> (f32, f32) {
58        match self {
59            AnimValue::Color(_) => (SPRING_EPSILON_DISP_COLOR, SPRING_EPSILON_VEL_COLOR),
60            AnimValue::Float(_) => (SPRING_EPSILON_DISP_FLOAT, SPRING_EPSILON_VEL_FLOAT),
61        }
62    }
63
64    /// Decompose into spring-integrable f32 channels. Colors decompose
65    /// to [Oklab L, a, b, alpha] so spring physics produces perceptually
66    /// uniform mid-flight values — no muddy gray midpoint on
67    /// complementary lerps.
68    pub fn channels(self) -> AnimChannels {
69        match self {
70            AnimValue::Float(v) => AnimChannels {
71                n: 1,
72                v: [v, 0.0, 0.0, 0.0],
73            },
74            AnimValue::Color(c) => {
75                let lab = c.to_oklab();
76                AnimChannels {
77                    n: 4,
78                    v: [lab.l, lab.a, lab.b, lab.alpha],
79                }
80            }
81        }
82    }
83
84    /// Reconstruct an `AnimValue` of the same variant from sampled
85    /// channels. The token name is dropped — an in-flight interpolated
86    /// rgba doesn't equal any palette token's rgb, so carrying a name
87    /// on it would mislead palette resolution. When the animation
88    /// settles, `step_spring` / `step_tween` assign
89    /// `self.current = self.target` directly, restoring the target's
90    /// token on the final value. Channel space (and the target's
91    /// [`crate::color::ColorSpace`]) is recovered from the previous-frame
92    /// value (`self`) so spring overshoot stays in the space the author
93    /// authored in.
94    pub fn from_channels(self, ch: AnimChannels) -> AnimValue {
95        match self {
96            AnimValue::Float(_) => AnimValue::Float(ch.v[0]),
97            AnimValue::Color(prev) => {
98                let lab = Oklab {
99                    l: ch.v[0],
100                    a: ch.v[1],
101                    b: ch.v[2],
102                    alpha: ch.v[3],
103                };
104                AnimValue::Color(lab.to_color(prev.space))
105            }
106        }
107    }
108}
109
110/// Fixed-capacity channel buffer the integrator steps: `n` live f32
111/// channels in `v` (1 for [`AnimValue::Float`], 4 for [`AnimValue::Color`]).
112#[derive(Clone, Copy, Debug)]
113pub struct AnimChannels {
114    /// Number of live channels; entries of `v` beyond it are unused.
115    pub n: usize,
116    /// Channel values — only the first `n` are meaningful.
117    pub v: [f32; 4],
118}
119
120impl AnimChannels {
121    /// All-zero channels of width `n` (e.g. a rest velocity).
122    pub fn zero(n: usize) -> Self {
123        Self { n, v: [0.0; 4] }
124    }
125}
126
127/// Spring physics configuration: mass-spring-damper.
128///
129/// The four preset constants are calibrated to feel competitive with
130/// modern native motion (UIKit defaults, Material 3 motion). Authors
131/// pick a preset; ad-hoc tuning is intentionally not exposed to keep
132/// the surface area small.
133#[derive(Clone, Copy, Debug)]
134pub struct SpringConfig {
135    /// Mass `m` in `m·a = -k·x - c·v`. Presets keep it at 1.0.
136    pub mass: f32,
137    /// Spring constant `k` — restoring force per unit displacement.
138    /// Higher settles faster.
139    pub stiffness: f32,
140    /// Damping coefficient `c` — force opposing velocity. Lower
141    /// relative to `k` means more overshoot.
142    pub damping: f32,
143}
144
145impl SpringConfig {
146    /// High stiffness, near-critical damping. ~150 ms settle, no
147    /// overshoot. Use for hover / focus where overshoot reads as jitter.
148    pub const QUICK: Self = Self {
149        mass: 1.0,
150        stiffness: 380.0,
151        damping: 30.0,
152    };
153    /// Balanced. ~250 ms settle, mild overshoot. Default state changes.
154    pub const STANDARD: Self = Self {
155        mass: 1.0,
156        stiffness: 200.0,
157        damping: 22.0,
158    };
159    /// Visible overshoot. Press-release rebound, playful interactions.
160    pub const BOUNCY: Self = Self {
161        mass: 1.0,
162        stiffness: 240.0,
163        damping: 14.0,
164    };
165    /// Soft, large displacements. Modal appearance, panel transitions.
166    pub const GENTLE: Self = Self {
167        mass: 1.0,
168        stiffness: 80.0,
169        damping: 18.0,
170    };
171}
172
173/// Cubic-bezier tween: P0=(0,0), P3=(1,1), with two control points.
174#[derive(Clone, Copy, Debug)]
175pub struct TweenConfig {
176    /// Total tween duration — the sample reaches the target exactly
177    /// when this much time has elapsed since `started_at`.
178    pub duration: Duration,
179    /// First control point `(x1, y1)`, CSS `cubic-bezier` convention.
180    pub p1: (f32, f32),
181    /// Second control point `(x2, y2)`.
182    pub p2: (f32, f32),
183}
184
185impl TweenConfig {
186    /// 100 ms ease-out. For micro-interactions where physics is overkill.
187    pub const EASE_QUICK: Self = Self {
188        duration: Duration::from_millis(100),
189        p1: (0.0, 0.0),
190        p2: (0.2, 1.0),
191    };
192    /// 200 ms ease-in-out. Symmetric default tween.
193    pub const EASE_STANDARD: Self = Self {
194        duration: Duration::from_millis(200),
195        p1: (0.4, 0.0),
196        p2: (0.2, 1.0),
197    };
198    /// 350 ms slow-out, fast-end. For larger displacements where the
199    /// final settle should feel decisive.
200    pub const EASE_EMPHASIZED: Self = Self {
201        duration: Duration::from_millis(350),
202        p1: (0.05, 0.7),
203        p2: (0.1, 1.0),
204    };
205}
206
207/// Choice of motion model for an animated property. Springs feel
208/// physical (continue from current+velocity on retarget); tweens feel
209/// curated (fixed curve, fixed duration).
210#[derive(Clone, Copy, Debug)]
211pub enum Timing {
212    /// Mass-spring-damper physics with the given configuration.
213    Spring(SpringConfig),
214    /// Fixed-duration cubic-bezier tween.
215    Tween(TweenConfig),
216}
217
218impl Timing {
219    /// [`SpringConfig::QUICK`] as a `Timing`.
220    pub const SPRING_QUICK: Self = Timing::Spring(SpringConfig::QUICK);
221    /// [`SpringConfig::STANDARD`] as a `Timing`.
222    pub const SPRING_STANDARD: Self = Timing::Spring(SpringConfig::STANDARD);
223    /// [`SpringConfig::BOUNCY`] as a `Timing`.
224    pub const SPRING_BOUNCY: Self = Timing::Spring(SpringConfig::BOUNCY);
225    /// [`SpringConfig::GENTLE`] as a `Timing`.
226    pub const SPRING_GENTLE: Self = Timing::Spring(SpringConfig::GENTLE);
227    /// [`TweenConfig::EASE_QUICK`] as a `Timing`.
228    pub const EASE_QUICK: Self = Timing::Tween(TweenConfig::EASE_QUICK);
229    /// [`TweenConfig::EASE_STANDARD`] as a `Timing`.
230    pub const EASE_STANDARD: Self = Timing::Tween(TweenConfig::EASE_STANDARD);
231    /// [`TweenConfig::EASE_EMPHASIZED`] as a `Timing`.
232    pub const EASE_EMPHASIZED: Self = Timing::Tween(TweenConfig::EASE_EMPHASIZED);
233}
234
235/// Identifies a specific animatable property on a node. Used as part
236/// of the per-(node, prop) tracker key.
237///
238/// Two families:
239///
240/// - **State envelopes** (`HoverAmount`, `PressAmount`, `FocusRingAlpha`)
241///   are 0..1 floats tracking *how much* of the corresponding state's
242///   visual delta is currently applied. The library updates these on
243///   every keyed interactive node automatically; no author opt-in. Why
244///   envelopes and not absolute colours: `apply_state` in `draw_ops`
245///   computes the display colour by lerping between `n.fill` and
246///   `state_color(n.fill)` based on the envelope. That keeps state
247///   easing completely independent of build-value changes — when the
248///   author swaps a button's fill mid-hover, the new fill takes effect
249///   instantly with the same hover envelope, no fighting between
250///   trackers.
251/// - **App-driven absolute values** (`App*`) are author-opted-in via
252///   [`crate::tree::El::animate`]. The tracker eases the value the build
253///   closure produces from the previous frame's value to the new one.
254#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
255#[non_exhaustive]
256pub enum AnimProp {
257    /// 0..1 amount of the hover-state visual delta currently applied.
258    /// Eases 0→1 on pointer enter, 1→0 on pointer leave.
259    HoverAmount,
260    /// 0..1 amount of the press-state visual delta currently applied.
261    /// Eases 0→1 on press, 1→0 on release.
262    PressAmount,
263    /// Focus-ring alpha — eases 0→1 on focus enter, 1→0 on focus leave.
264    /// Lets the ring fade out after focus moves elsewhere.
265    FocusRingAlpha,
266    /// 0..1 amount tracking "is the hover target this node or any
267    /// descendant?". Eases 0→1 when the cursor enters the subtree, 1→0
268    /// when it leaves. Drives region-shaped hover affordances
269    /// (`hover_alpha`, future hover-driven translate / scale / tint).
270    SubtreeHoverAmount,
271    /// 0..1 amount tracking "is the press target this node or any
272    /// descendant?". Subtree analogue of `PressAmount`.
273    SubtreePressAmount,
274    /// 0..1 amount tracking "is the focus target this node or any
275    /// descendant?". Subtree analogue of `FocusRingAlpha`. Composed
276    /// with `SubtreeHoverAmount` by `hover_alpha` so keyboard focus
277    /// reveals the same affordance hover does.
278    SubtreeFocusAmount,
279    /// App-driven fill colour — eases between the values the build
280    /// closure produces across rebuilds.
281    AppFill,
282    /// App-driven stroke colour.
283    AppStroke,
284    /// App-driven text colour.
285    AppTextColor,
286    /// App-driven paint-time alpha multiplier in `[0, 1]`.
287    AppOpacity,
288    /// App-driven uniform scale around the rect centre.
289    AppScale,
290    /// App-driven translate offset in logical pixels — X channel.
291    AppTranslateX,
292    /// App-driven translate offset in logical pixels — Y channel.
293    AppTranslateY,
294}
295
296// Settle thresholds vary by AnimValue type since their channels live in
297// very different magnitudes:
298//
299// - `AnimValue::Color` decomposes to Oklab (`L`, `a`, `b`, `alpha`) in
300//   roughly `[-1, 1]`. ~0.5 sRGB-u8 levels of channel difference corresponds
301//   to ~0.002 in Oklab L.
302// - `AnimValue::Float` is whatever the author put in — typically `[0, 1]`
303//   envelopes or logical-pixel translate offsets. The historical 0.5
304//   threshold was tuned for the pixel case and is comfortably below
305//   perceptual jitter for [0, 1] envelopes.
306const SPRING_EPSILON_DISP_COLOR: f32 = 0.002;
307const SPRING_EPSILON_VEL_COLOR: f32 = 0.005;
308const SPRING_EPSILON_DISP_FLOAT: f32 = 0.5;
309const SPRING_EPSILON_VEL_FLOAT: f32 = 0.5;
310const DT_CAP: f32 = 0.064;
311/// Hard upper bound on the per-substep timestep used inside `step_spring`.
312/// The semi-implicit Euler scheme with explicit damping is stable for
313/// `dt < 2·sqrt(m/k) + small damping correction`; the stiffest preset
314/// (`SpringConfig::QUICK`, k=380, c=30) has a stability bound near 58 ms.
315/// `DT_CAP` (64 ms) sits above that, so without substepping the integrator
316/// can blow up after long idle pauses or on slow frames — `current`
317/// overshoots into ±values and the 0..1 envelope `clamp` rounds to a
318/// binary flicker. 4 ms keeps every preset comfortably stable.
319const SPRING_MAX_SUBSTEP: f32 = 1.0 / 250.0;
320
321/// In-flight animation state for one (node, prop) pair. Stored on
322/// [`crate::state::UiState`] keyed by `(ComputedId, AnimProp)`.
323///
324/// `current` is the read-back view consumed by `write_prop` — for
325/// `AnimValue::Color` that's u8 rgba. The integrator's per-frame
326/// motion near equilibrium is sub-integer in rgb units (typical
327/// `vel * dt ≈ 0.1–0.4` once the spring is close to target), so
328/// integrating against the rounded view loses fractional progress
329/// every frame and the integrator freezes a few rgb units off
330/// target. `current_precise` is the lossless f32 mirror integrators
331/// actually read and write across ticks.
332#[derive(Clone, Debug)]
333#[non_exhaustive]
334pub struct Animation {
335    /// Current sampled value — the read-back view `write_prop` consumes
336    /// (u8-rounded for colors; see struct docs).
337    pub current: AnimValue,
338    /// Value the animation is heading toward. Must be the same
339    /// [`AnimValue`] variant as `current`.
340    pub target: AnimValue,
341    /// Per-channel velocity — spring integrator state. Zero for tweens.
342    pub velocity: AnimChannels,
343    /// Motion model driving this animation.
344    pub timing: Timing,
345    /// When the animation (or, after a retarget, the current tween
346    /// segment) began. Tweens measure elapsed time from here.
347    pub started_at: Instant,
348    /// Instant of the previous [`Animation::step`]; the next step
349    /// integrates `now - last_step`, clamped to 64 ms.
350    pub last_step: Instant,
351    /// For tweens, the value at `started_at`. Springs are fully
352    /// determined by current+velocity, so `from` stays `None`.
353    pub from: Option<AnimValue>,
354    /// Lossless f32 mirror of `current` for the integrator. See struct
355    /// doc — `AnimValue::Color` stores u8, which silently freezes the
356    /// spring once per-frame motion drops below 0.5 rgb units.
357    current_precise: AnimChannels,
358}
359
360impl Animation {
361    /// Start an animation at `current` heading toward `target`, with
362    /// zero initial velocity and clocks set to `now`.
363    pub fn new(current: AnimValue, target: AnimValue, timing: Timing, now: Instant) -> Self {
364        let channels = current.channels();
365        let n = channels.n;
366        let from = match timing {
367            Timing::Tween(_) => Some(current),
368            Timing::Spring(_) => None,
369        };
370        Self {
371            current,
372            target,
373            velocity: AnimChannels::zero(n),
374            timing,
375            started_at: now,
376            last_step: now,
377            from,
378            current_precise: channels,
379        }
380    }
381
382    /// Re-target a running animation. Current value and velocity carry
383    /// over so interrupted motion eases from where it is, not from rest.
384    /// For tweens, `from` snaps to the current sample so the new curve
385    /// starts there; the tween clock resets.
386    pub fn retarget(&mut self, target: AnimValue, now: Instant) {
387        if same_value(self.target, target) {
388            return;
389        }
390        self.target = target;
391        if matches!(self.timing, Timing::Tween(_)) {
392            self.from = Some(self.current);
393            self.started_at = now;
394        }
395        // Springs: keep current+velocity untouched. The integrator now
396        // sees a different `target` and forces will steer toward it.
397    }
398
399    /// Snap to target and zero velocity. Used by the headless bundle
400    /// path so SVG/PNG fixtures don't depend on integrator timing.
401    pub fn settle(&mut self) {
402        self.current = self.target;
403        self.current_precise = self.target.channels();
404        let n = self.current_precise.n;
405        self.velocity = AnimChannels::zero(n);
406        self.from = None;
407    }
408
409    /// Step the animation forward to `now`. Returns `true` if settled.
410    pub fn step(&mut self, now: Instant) -> bool {
411        let dt = now
412            .saturating_duration_since(self.last_step)
413            .as_secs_f32()
414            .min(DT_CAP);
415        self.last_step = now;
416        match self.timing {
417            Timing::Spring(cfg) => self.step_spring(cfg, dt),
418            Timing::Tween(cfg) => self.step_tween(cfg, now),
419        }
420    }
421
422    fn step_spring(&mut self, cfg: SpringConfig, dt: f32) -> bool {
423        if dt <= 0.0 {
424            return self.is_settled();
425        }
426        let (eps_disp, eps_vel) = self.target.settle_thresholds();
427        let mut cur = if self.current_precise.n == self.current.channels().n {
428            self.current_precise
429        } else {
430            self.current.channels()
431        };
432        let tgt = self.target.channels();
433        let mut vel = if self.velocity.n == cur.n {
434            self.velocity
435        } else {
436            AnimChannels::zero(cur.n)
437        };
438        // Substep so each integrator step is well within the stability
439        // bound for every SpringConfig preset. A single h = `dt` step
440        // would diverge for stiff presets when frames stall or the host
441        // resumes after a long idle (dt clamped to DT_CAP > stability
442        // bound for QUICK), producing binary 0/1 flicker once `current`
443        // overshoots into ±range and write_prop's clamp rounds it.
444        let n_steps = (dt / SPRING_MAX_SUBSTEP).ceil().max(1.0) as usize;
445        let h = dt / n_steps as f32;
446        let mut all_settled = false;
447        for _ in 0..n_steps {
448            all_settled = true;
449            for i in 0..cur.n {
450                let displacement = cur.v[i] - tgt.v[i];
451                let force = -cfg.stiffness * displacement - cfg.damping * vel.v[i];
452                // Semi-implicit Euler: update velocity first, then position
453                // using the new velocity. More stable than fully explicit
454                // for stiff systems within UI's typical stiffness range.
455                vel.v[i] += (force / cfg.mass) * h;
456                cur.v[i] += vel.v[i] * h;
457                if displacement.abs() > eps_disp || vel.v[i].abs() > eps_vel {
458                    all_settled = false;
459                }
460            }
461            if all_settled {
462                break;
463            }
464        }
465        if all_settled {
466            self.current = self.target;
467            self.current_precise = tgt;
468            self.velocity = AnimChannels::zero(cur.n);
469            return true;
470        }
471        self.current_precise = cur;
472        self.current = self.current.from_channels(cur);
473        self.velocity = vel;
474        false
475    }
476
477    fn step_tween(&mut self, cfg: TweenConfig, now: Instant) -> bool {
478        let elapsed = now.saturating_duration_since(self.started_at);
479        if elapsed >= cfg.duration {
480            self.current = self.target;
481            self.current_precise = self.target.channels();
482            return true;
483        }
484        let from = self.from.unwrap_or(self.current).channels();
485        let tgt = self.target.channels();
486        let t = elapsed.as_secs_f32() / cfg.duration.as_secs_f32();
487        let eased = cubic_bezier_y_at_x(t, cfg.p1, cfg.p2);
488        let mut next = AnimChannels {
489            n: from.n,
490            v: [0.0; 4],
491        };
492        for i in 0..from.n {
493            next.v[i] = from.v[i] + (tgt.v[i] - from.v[i]) * eased;
494        }
495        self.current_precise = next;
496        self.current = self.current.from_channels(next);
497        false
498    }
499
500    fn is_settled(&self) -> bool {
501        let (_, eps_vel) = self.target.settle_thresholds();
502        same_value(self.current, self.target)
503            && (0..self.velocity.n).all(|i| self.velocity.v[i].abs() <= eps_vel)
504    }
505}
506
507fn same_value(a: AnimValue, b: AnimValue) -> bool {
508    let ca = a.channels();
509    let cb = b.channels();
510    if ca.n != cb.n {
511        return false;
512    }
513    (0..ca.n).all(|i| (ca.v[i] - cb.v[i]).abs() < f32::EPSILON)
514}
515
516/// Solve `cubic_bezier(t).x == x` for `t`, then return `cubic_bezier(t).y`.
517/// P0=(0,0), P3=(1,1). Newton-Raphson with binary-search fallback.
518fn cubic_bezier_y_at_x(x: f32, p1: (f32, f32), p2: (f32, f32)) -> f32 {
519    if x <= 0.0 {
520        return 0.0;
521    }
522    if x >= 1.0 {
523        return 1.0;
524    }
525    // Newton-Raphson on x(t) — converges in 4-6 iterations for typical
526    // ease curves. Fall back to bisection if the derivative collapses.
527    let mut t = x;
528    for _ in 0..8 {
529        let xt = bezier_axis(t, p1.0, p2.0);
530        let dx = bezier_axis_derivative(t, p1.0, p2.0);
531        if dx.abs() < 1e-6 {
532            break;
533        }
534        let next = t - (xt - x) / dx;
535        if (next - t).abs() < 1e-5 {
536            t = next.clamp(0.0, 1.0);
537            break;
538        }
539        t = next.clamp(0.0, 1.0);
540    }
541    bezier_axis(t, p1.1, p2.1)
542}
543
544/// Cubic Bezier polynomial: B(t) = 3·(1-t)²·t·c1 + 3·(1-t)·t²·c2 + t³.
545/// P0 and P3 are pinned at 0 and 1 (no contribution beyond the t³ term).
546fn bezier_axis(t: f32, c1: f32, c2: f32) -> f32 {
547    let one_minus_t = 1.0 - t;
548    3.0 * one_minus_t * one_minus_t * t * c1 + 3.0 * one_minus_t * t * t * c2 + t * t * t
549}
550
551fn bezier_axis_derivative(t: f32, c1: f32, c2: f32) -> f32 {
552    let one_minus_t = 1.0 - t;
553    3.0 * one_minus_t * one_minus_t * c1
554        + 6.0 * one_minus_t * t * (c2 - c1)
555        + 3.0 * t * t * (1.0 - c2)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn now_plus(start: Instant, ms: u64) -> Instant {
563        start + Duration::from_millis(ms)
564    }
565
566    #[test]
567    fn spring_settles_to_target() {
568        let start = Instant::now();
569        let mut a = Animation::new(
570            AnimValue::Float(0.0),
571            AnimValue::Float(1.0),
572            Timing::SPRING_QUICK,
573            start,
574        );
575        let mut t = start;
576        for _ in 0..200 {
577            t += Duration::from_millis(8);
578            if a.step(t) {
579                break;
580            }
581        }
582        let AnimValue::Float(v) = a.current else {
583            panic!("expected float")
584        };
585        assert!((v - 1.0).abs() < 1e-3, "spring did not settle: v={v}");
586    }
587
588    #[test]
589    fn spring_retarget_preserves_velocity() {
590        // Start moving 0 → 1; mid-flight retarget back to 0 should
591        // briefly continue past the new target before reversing —
592        // momentum carries.
593        let start = Instant::now();
594        let mut a = Animation::new(
595            AnimValue::Float(0.0),
596            AnimValue::Float(1.0),
597            Timing::SPRING_STANDARD,
598            start,
599        );
600        let mut t = start;
601        for _ in 0..15 {
602            t += Duration::from_millis(8);
603            a.step(t);
604        }
605        let mid = match a.current {
606            AnimValue::Float(v) => v,
607            _ => unreachable!(),
608        };
609        assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}");
610        let velocity_before = a.velocity.v[0];
611        assert!(velocity_before > 0.0);
612        a.retarget(AnimValue::Float(0.0), t);
613        // Velocity is preserved — the spring will continue forward briefly.
614        assert_eq!(a.velocity.v[0], velocity_before);
615    }
616
617    #[test]
618    fn tween_samples_endpoints() {
619        let start = Instant::now();
620        let mut a = Animation::new(
621            AnimValue::Float(10.0),
622            AnimValue::Float(20.0),
623            Timing::EASE_STANDARD,
624            start,
625        );
626        a.step(start);
627        let AnimValue::Float(v0) = a.current else {
628            panic!()
629        };
630        assert!(
631            (v0 - 10.0).abs() < 1e-3,
632            "tween at t=0 should equal `from`, got {v0}"
633        );
634
635        a.step(now_plus(start, 1000));
636        let AnimValue::Float(vend) = a.current else {
637            panic!()
638        };
639        assert!(
640            (vend - 20.0).abs() < 1e-3,
641            "tween past duration should equal target, got {vend}"
642        );
643    }
644
645    #[test]
646    fn tween_retarget_snaps_from_to_current() {
647        let start = Instant::now();
648        let mut a = Animation::new(
649            AnimValue::Float(0.0),
650            AnimValue::Float(100.0),
651            Timing::EASE_STANDARD,
652            start,
653        );
654        a.step(now_plus(start, 100));
655        let AnimValue::Float(mid) = a.current else {
656            panic!()
657        };
658        a.retarget(AnimValue::Float(0.0), now_plus(start, 100));
659        assert_eq!(a.from, Some(AnimValue::Float(mid)));
660    }
661
662    #[test]
663    fn settle_snaps_to_target() {
664        let start = Instant::now();
665        let mut a = Animation::new(
666            AnimValue::Color(Color::srgb_u8a(0, 0, 0, 255)),
667            AnimValue::Color(Color::srgb_u8a(255, 128, 0, 255)),
668            Timing::SPRING_STANDARD,
669            start,
670        );
671        a.step(now_plus(start, 5));
672        a.settle();
673        match a.current {
674            AnimValue::Color(c) => {
675                assert_eq!(c.to_srgb_u8a(), [255, 128, 0, 255]);
676            }
677            _ => panic!("expected color"),
678        }
679        assert!(a.velocity.v.iter().all(|&v| v == 0.0));
680    }
681
682    #[test]
683    fn cubic_bezier_endpoints_pin() {
684        // Any curve must satisfy P(0)=0 and P(1)=1.
685        let p1 = (0.4, 0.0);
686        let p2 = (0.2, 1.0);
687        assert!((cubic_bezier_y_at_x(0.0, p1, p2) - 0.0).abs() < 1e-3);
688        assert!((cubic_bezier_y_at_x(1.0, p1, p2) - 1.0).abs() < 1e-3);
689    }
690
691    #[test]
692    fn color_channels_round_trip() {
693        // Channels are Oklab (L, a, b, alpha) so spring physics
694        // interpolates perceptually. Round trip via the same Color's
695        // space recovers the input to within float precision.
696        let c = Color::srgb_u8a(42, 17, 200, 255);
697        let v = AnimValue::Color(c);
698        let ch = v.channels();
699        assert_eq!(ch.n, 4);
700        let back = v.from_channels(ch);
701        let AnimValue::Color(back) = back else {
702            panic!("expected color");
703        };
704        let [r, g, b, a] = back.to_srgb_u8a();
705        assert_eq!(
706            [r, g, b, a],
707            [42, 17, 200, 255],
708            "round-trip should recover the source rgba within u8 precision"
709        );
710    }
711
712    #[test]
713    fn from_channels_drops_token_on_in_flight_eased_value() {
714        // An in-flight eased rgba is not the same color as the source
715        // token — keeping the token name on it would let palette
716        // resolution snap the rgb back to the source token's palette
717        // value, killing the transition. Spring/tween settled paths
718        // bypass `from_channels` and assign `self.current = self.target`
719        // directly, so settled values still carry the target's token.
720        let v = AnimValue::Color(Color::srgb_token("primary", 92, 170, 255, 255));
721        // Mid-flight: synthesize a halfway Oklab between the source and
722        // a different target. Channel semantics are Oklab (L, a, b, alpha).
723        let start = Color::srgb_u8(92, 170, 255).to_oklab();
724        let end = Color::srgb_u8(255, 100, 80).to_oklab();
725        let mid_lab = Oklab {
726            l: (start.l + end.l) * 0.5,
727            a: (start.a + end.a) * 0.5,
728            b: (start.b + end.b) * 0.5,
729            alpha: 1.0,
730        };
731        let mid = AnimChannels {
732            n: 4,
733            v: [mid_lab.l, mid_lab.a, mid_lab.b, mid_lab.alpha],
734        };
735        let eased = v.from_channels(mid);
736        match eased {
737            AnimValue::Color(c) => {
738                assert_eq!(c.token, None, "in-flight eased color must drop the token");
739                // The mid-flight value must lie strictly between start
740                // and end on each Oklab axis (perceptually mid).
741                let lab = c.to_oklab();
742                let lo_l = start.l.min(end.l);
743                let hi_l = start.l.max(end.l);
744                assert!(lab.l >= lo_l && lab.l <= hi_l, "L out of range");
745            }
746            _ => panic!("expected color"),
747        }
748    }
749}