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/// Declarative enter transition for a node's first mounted frame — the
297/// Radix/tailwindcss-animate `data-[state=open]` idiom (`fade-in-0
298/// zoom-in-95 slide-in-from-top-2`) as a value. When a keyed node
299/// carrying one of these appears in the tree for the first time, its
300/// app-driven prop trackers are *seeded* at the `from` values below and
301/// ease to the values the build produced, instead of starting settled.
302/// Structural removal stays instant (Radix's exit animations need
303/// unmount ghosting, which Damascene deliberately doesn't do); managers
304/// that own their own lifecycle (toasts) animate exits by retargeting
305/// props while the node is still mounted.
306///
307/// Composes with [`crate::tree::El::animate`]: `enter` alone is enough
308/// to tick the app props (no separate opt-in), and later rebuild-driven
309/// retargets use the node's `animate` timing when set, this `timing`
310/// otherwise.
311#[derive(Clone, Copy, Debug)]
312pub struct EnterTransition {
313    /// Starting opacity (absolute; target is the built `opacity`).
314    /// `None` leaves opacity unanimated.
315    pub opacity: Option<f32>,
316    /// Starting uniform scale (absolute; target is the built `scale`).
317    pub scale: Option<f32>,
318    /// Starting translate *offset* from the built position in logical
319    /// px — `(0.0, -8.0)` slides in from 8px above, like
320    /// `slide-in-from-top-2`.
321    pub translate: Option<(f32, f32)>,
322    /// Motion used for the seeded enter (and for later retargets when
323    /// the node has no explicit `animate` timing).
324    pub timing: Timing,
325}
326
327impl EnterTransition {
328    /// Fade in from fully transparent (`fade-in-0`).
329    pub const fn fade() -> Self {
330        Self {
331            opacity: Some(0.0),
332            scale: None,
333            translate: None,
334            timing: Timing::SPRING_QUICK,
335        }
336    }
337
338    /// Fade + scale up from 95% (`fade-in-0 zoom-in-95`) — the shadcn
339    /// overlay-panel entrance. `scale` is a paint-time subtree
340    /// transform (CSS `transform: scale()` semantics), so this works
341    /// on containers: a popover zooming in carries its children.
342    pub const fn zoom() -> Self {
343        Self {
344            opacity: Some(0.0),
345            scale: Some(0.95),
346            translate: None,
347            timing: Timing::SPRING_QUICK,
348        }
349    }
350
351    /// Slide in from `(dx, dy)` px away, without fading — the sheet /
352    /// drawer entrance.
353    pub const fn slide(dx: f32, dy: f32) -> Self {
354        Self {
355            opacity: None,
356            scale: None,
357            translate: Some((dx, dy)),
358            timing: Timing::SPRING_STANDARD,
359        }
360    }
361
362    /// Add a slide offset to an existing transition (e.g.
363    /// `EnterTransition::zoom().with_slide(0.0, -4.0)` for a menu that
364    /// settles downward from its trigger).
365    pub const fn with_slide(mut self, dx: f32, dy: f32) -> Self {
366        self.translate = Some((dx, dy));
367        self
368    }
369
370    /// Override the motion timing.
371    pub const fn with_timing(mut self, timing: Timing) -> Self {
372        self.timing = timing;
373        self
374    }
375
376    /// The seed value for `prop`, given the built (target) value —
377    /// `None` when this transition doesn't animate that prop.
378    pub(crate) fn seed_for(&self, prop: AnimProp, built: AnimValue) -> Option<AnimValue> {
379        let AnimValue::Float(built) = built else {
380            return None;
381        };
382        match prop {
383            AnimProp::AppOpacity => self.opacity.map(AnimValue::Float),
384            AnimProp::AppScale => self.scale.map(AnimValue::Float),
385            AnimProp::AppTranslateX => self
386                .translate
387                .filter(|t| t.0 != 0.0)
388                .map(|t| AnimValue::Float(built + t.0)),
389            AnimProp::AppTranslateY => self
390                .translate
391                .filter(|t| t.1 != 0.0)
392                .map(|t| AnimValue::Float(built + t.1)),
393            _ => None,
394        }
395    }
396}
397
398// Settle thresholds vary by AnimValue type since their channels live in
399// very different magnitudes:
400//
401// - `AnimValue::Color` decomposes to Oklab (`L`, `a`, `b`, `alpha`) in
402//   roughly `[-1, 1]`. ~0.5 sRGB-u8 levels of channel difference corresponds
403//   to ~0.002 in Oklab L.
404// - `AnimValue::Float` is whatever the author put in — typically `[0, 1]`
405//   envelopes or logical-pixel translate offsets. The historical 0.5
406//   threshold was tuned for the pixel case and is comfortably below
407//   perceptual jitter for [0, 1] envelopes.
408const SPRING_EPSILON_DISP_COLOR: f32 = 0.002;
409const SPRING_EPSILON_VEL_COLOR: f32 = 0.005;
410const SPRING_EPSILON_DISP_FLOAT: f32 = 0.5;
411const SPRING_EPSILON_VEL_FLOAT: f32 = 0.5;
412const DT_CAP: f32 = 0.064;
413/// Hard upper bound on the per-substep timestep used inside `step_spring`.
414/// The semi-implicit Euler scheme with explicit damping is stable for
415/// `dt < 2·sqrt(m/k) + small damping correction`; the stiffest preset
416/// (`SpringConfig::QUICK`, k=380, c=30) has a stability bound near 58 ms.
417/// `DT_CAP` (64 ms) sits above that, so without substepping the integrator
418/// can blow up after long idle pauses or on slow frames — `current`
419/// overshoots into ±values and the 0..1 envelope `clamp` rounds to a
420/// binary flicker. 4 ms keeps every preset comfortably stable.
421const SPRING_MAX_SUBSTEP: f32 = 1.0 / 250.0;
422
423/// In-flight animation state for one (node, prop) pair. Stored on
424/// [`crate::state::UiState`] keyed by `(ComputedId, AnimProp)`.
425///
426/// `current` is the read-back view consumed by `write_prop` — for
427/// `AnimValue::Color` that's u8 rgba. The integrator's per-frame
428/// motion near equilibrium is sub-integer in rgb units (typical
429/// `vel * dt ≈ 0.1–0.4` once the spring is close to target), so
430/// integrating against the rounded view loses fractional progress
431/// every frame and the integrator freezes a few rgb units off
432/// target. `current_precise` is the lossless f32 mirror integrators
433/// actually read and write across ticks.
434#[derive(Clone, Debug)]
435#[non_exhaustive]
436pub struct Animation {
437    /// Current sampled value — the read-back view `write_prop` consumes
438    /// (u8-rounded for colors; see struct docs).
439    pub current: AnimValue,
440    /// Value the animation is heading toward. Must be the same
441    /// [`AnimValue`] variant as `current`.
442    pub target: AnimValue,
443    /// Per-channel velocity — spring integrator state. Zero for tweens.
444    pub velocity: AnimChannels,
445    /// Motion model driving this animation.
446    pub timing: Timing,
447    /// When the animation (or, after a retarget, the current tween
448    /// segment) began. Tweens measure elapsed time from here.
449    pub started_at: Instant,
450    /// Instant of the previous [`Animation::step`]; the next step
451    /// integrates `now - last_step`, clamped to 64 ms.
452    pub last_step: Instant,
453    /// For tweens, the value at `started_at`. Springs are fully
454    /// determined by current+velocity, so `from` stays `None`.
455    pub from: Option<AnimValue>,
456    /// Lossless f32 mirror of `current` for the integrator. See struct
457    /// doc — `AnimValue::Color` stores u8, which silently freezes the
458    /// spring once per-frame motion drops below 0.5 rgb units.
459    current_precise: AnimChannels,
460}
461
462impl Animation {
463    /// Start an animation at `current` heading toward `target`, with
464    /// zero initial velocity and clocks set to `now`.
465    pub fn new(current: AnimValue, target: AnimValue, timing: Timing, now: Instant) -> Self {
466        let channels = current.channels();
467        let n = channels.n;
468        let from = match timing {
469            Timing::Tween(_) => Some(current),
470            Timing::Spring(_) => None,
471        };
472        Self {
473            current,
474            target,
475            velocity: AnimChannels::zero(n),
476            timing,
477            started_at: now,
478            last_step: now,
479            from,
480            current_precise: channels,
481        }
482    }
483
484    /// Re-target a running animation. Current value and velocity carry
485    /// over so interrupted motion eases from where it is, not from rest.
486    /// For tweens, `from` snaps to the current sample so the new curve
487    /// starts there; the tween clock resets.
488    pub fn retarget(&mut self, target: AnimValue, now: Instant) {
489        if same_value(self.target, target) {
490            return;
491        }
492        self.target = target;
493        if matches!(self.timing, Timing::Tween(_)) {
494            self.from = Some(self.current);
495            self.started_at = now;
496        }
497        // Springs: keep current+velocity untouched. The integrator now
498        // sees a different `target` and forces will steer toward it.
499    }
500
501    /// Snap to target and zero velocity. Used by the headless bundle
502    /// path so SVG/PNG fixtures don't depend on integrator timing.
503    pub fn settle(&mut self) {
504        self.current = self.target;
505        self.current_precise = self.target.channels();
506        let n = self.current_precise.n;
507        self.velocity = AnimChannels::zero(n);
508        self.from = None;
509    }
510
511    /// Step the animation forward to `now`. Returns `true` if settled.
512    pub fn step(&mut self, now: Instant) -> bool {
513        let dt = now
514            .saturating_duration_since(self.last_step)
515            .as_secs_f32()
516            .min(DT_CAP);
517        self.last_step = now;
518        match self.timing {
519            Timing::Spring(cfg) => self.step_spring(cfg, dt),
520            Timing::Tween(cfg) => self.step_tween(cfg, now),
521        }
522    }
523
524    fn step_spring(&mut self, cfg: SpringConfig, dt: f32) -> bool {
525        if dt <= 0.0 {
526            return self.is_settled();
527        }
528        let (eps_disp, eps_vel) = self.target.settle_thresholds();
529        let mut cur = if self.current_precise.n == self.current.channels().n {
530            self.current_precise
531        } else {
532            self.current.channels()
533        };
534        let tgt = self.target.channels();
535        let mut vel = if self.velocity.n == cur.n {
536            self.velocity
537        } else {
538            AnimChannels::zero(cur.n)
539        };
540        // Substep so each integrator step is well within the stability
541        // bound for every SpringConfig preset. A single h = `dt` step
542        // would diverge for stiff presets when frames stall or the host
543        // resumes after a long idle (dt clamped to DT_CAP > stability
544        // bound for QUICK), producing binary 0/1 flicker once `current`
545        // overshoots into ±range and write_prop's clamp rounds it.
546        let n_steps = (dt / SPRING_MAX_SUBSTEP).ceil().max(1.0) as usize;
547        let h = dt / n_steps as f32;
548        let mut all_settled = false;
549        for _ in 0..n_steps {
550            all_settled = true;
551            for i in 0..cur.n {
552                let displacement = cur.v[i] - tgt.v[i];
553                let force = -cfg.stiffness * displacement - cfg.damping * vel.v[i];
554                // Semi-implicit Euler: update velocity first, then position
555                // using the new velocity. More stable than fully explicit
556                // for stiff systems within UI's typical stiffness range.
557                vel.v[i] += (force / cfg.mass) * h;
558                cur.v[i] += vel.v[i] * h;
559                if displacement.abs() > eps_disp || vel.v[i].abs() > eps_vel {
560                    all_settled = false;
561                }
562            }
563            if all_settled {
564                break;
565            }
566        }
567        if all_settled {
568            self.current = self.target;
569            self.current_precise = tgt;
570            self.velocity = AnimChannels::zero(cur.n);
571            return true;
572        }
573        self.current_precise = cur;
574        self.current = self.current.from_channels(cur);
575        self.velocity = vel;
576        false
577    }
578
579    fn step_tween(&mut self, cfg: TweenConfig, now: Instant) -> bool {
580        let elapsed = now.saturating_duration_since(self.started_at);
581        if elapsed >= cfg.duration {
582            self.current = self.target;
583            self.current_precise = self.target.channels();
584            return true;
585        }
586        let from = self.from.unwrap_or(self.current).channels();
587        let tgt = self.target.channels();
588        let t = elapsed.as_secs_f32() / cfg.duration.as_secs_f32();
589        let eased = cubic_bezier_y_at_x(t, cfg.p1, cfg.p2);
590        let mut next = AnimChannels {
591            n: from.n,
592            v: [0.0; 4],
593        };
594        for i in 0..from.n {
595            next.v[i] = from.v[i] + (tgt.v[i] - from.v[i]) * eased;
596        }
597        self.current_precise = next;
598        self.current = self.current.from_channels(next);
599        false
600    }
601
602    fn is_settled(&self) -> bool {
603        let (_, eps_vel) = self.target.settle_thresholds();
604        same_value(self.current, self.target)
605            && (0..self.velocity.n).all(|i| self.velocity.v[i].abs() <= eps_vel)
606    }
607}
608
609fn same_value(a: AnimValue, b: AnimValue) -> bool {
610    let ca = a.channels();
611    let cb = b.channels();
612    if ca.n != cb.n {
613        return false;
614    }
615    (0..ca.n).all(|i| (ca.v[i] - cb.v[i]).abs() < f32::EPSILON)
616}
617
618/// Solve `cubic_bezier(t).x == x` for `t`, then return `cubic_bezier(t).y`.
619/// P0=(0,0), P3=(1,1). Newton-Raphson with binary-search fallback.
620fn cubic_bezier_y_at_x(x: f32, p1: (f32, f32), p2: (f32, f32)) -> f32 {
621    if x <= 0.0 {
622        return 0.0;
623    }
624    if x >= 1.0 {
625        return 1.0;
626    }
627    // Newton-Raphson on x(t) — converges in 4-6 iterations for typical
628    // ease curves. Fall back to bisection if the derivative collapses.
629    let mut t = x;
630    for _ in 0..8 {
631        let xt = bezier_axis(t, p1.0, p2.0);
632        let dx = bezier_axis_derivative(t, p1.0, p2.0);
633        if dx.abs() < 1e-6 {
634            break;
635        }
636        let next = t - (xt - x) / dx;
637        if (next - t).abs() < 1e-5 {
638            t = next.clamp(0.0, 1.0);
639            break;
640        }
641        t = next.clamp(0.0, 1.0);
642    }
643    bezier_axis(t, p1.1, p2.1)
644}
645
646/// Cubic Bezier polynomial: B(t) = 3·(1-t)²·t·c1 + 3·(1-t)·t²·c2 + t³.
647/// P0 and P3 are pinned at 0 and 1 (no contribution beyond the t³ term).
648fn bezier_axis(t: f32, c1: f32, c2: f32) -> f32 {
649    let one_minus_t = 1.0 - t;
650    3.0 * one_minus_t * one_minus_t * t * c1 + 3.0 * one_minus_t * t * t * c2 + t * t * t
651}
652
653fn bezier_axis_derivative(t: f32, c1: f32, c2: f32) -> f32 {
654    let one_minus_t = 1.0 - t;
655    3.0 * one_minus_t * one_minus_t * c1
656        + 6.0 * one_minus_t * t * (c2 - c1)
657        + 3.0 * t * t * (1.0 - c2)
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    fn now_plus(start: Instant, ms: u64) -> Instant {
665        start + Duration::from_millis(ms)
666    }
667
668    #[test]
669    fn spring_settles_to_target() {
670        let start = Instant::now();
671        let mut a = Animation::new(
672            AnimValue::Float(0.0),
673            AnimValue::Float(1.0),
674            Timing::SPRING_QUICK,
675            start,
676        );
677        let mut t = start;
678        for _ in 0..200 {
679            t += Duration::from_millis(8);
680            if a.step(t) {
681                break;
682            }
683        }
684        let AnimValue::Float(v) = a.current else {
685            panic!("expected float")
686        };
687        assert!((v - 1.0).abs() < 1e-3, "spring did not settle: v={v}");
688    }
689
690    #[test]
691    fn spring_retarget_preserves_velocity() {
692        // Start moving 0 → 1; mid-flight retarget back to 0 should
693        // briefly continue past the new target before reversing —
694        // momentum carries.
695        let start = Instant::now();
696        let mut a = Animation::new(
697            AnimValue::Float(0.0),
698            AnimValue::Float(1.0),
699            Timing::SPRING_STANDARD,
700            start,
701        );
702        let mut t = start;
703        for _ in 0..15 {
704            t += Duration::from_millis(8);
705            a.step(t);
706        }
707        let mid = match a.current {
708            AnimValue::Float(v) => v,
709            _ => unreachable!(),
710        };
711        assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}");
712        let velocity_before = a.velocity.v[0];
713        assert!(velocity_before > 0.0);
714        a.retarget(AnimValue::Float(0.0), t);
715        // Velocity is preserved — the spring will continue forward briefly.
716        assert_eq!(a.velocity.v[0], velocity_before);
717    }
718
719    #[test]
720    fn tween_samples_endpoints() {
721        let start = Instant::now();
722        let mut a = Animation::new(
723            AnimValue::Float(10.0),
724            AnimValue::Float(20.0),
725            Timing::EASE_STANDARD,
726            start,
727        );
728        a.step(start);
729        let AnimValue::Float(v0) = a.current else {
730            panic!()
731        };
732        assert!(
733            (v0 - 10.0).abs() < 1e-3,
734            "tween at t=0 should equal `from`, got {v0}"
735        );
736
737        a.step(now_plus(start, 1000));
738        let AnimValue::Float(vend) = a.current else {
739            panic!()
740        };
741        assert!(
742            (vend - 20.0).abs() < 1e-3,
743            "tween past duration should equal target, got {vend}"
744        );
745    }
746
747    #[test]
748    fn tween_retarget_snaps_from_to_current() {
749        let start = Instant::now();
750        let mut a = Animation::new(
751            AnimValue::Float(0.0),
752            AnimValue::Float(100.0),
753            Timing::EASE_STANDARD,
754            start,
755        );
756        a.step(now_plus(start, 100));
757        let AnimValue::Float(mid) = a.current else {
758            panic!()
759        };
760        a.retarget(AnimValue::Float(0.0), now_plus(start, 100));
761        assert_eq!(a.from, Some(AnimValue::Float(mid)));
762    }
763
764    #[test]
765    fn settle_snaps_to_target() {
766        let start = Instant::now();
767        let mut a = Animation::new(
768            AnimValue::Color(Color::srgb_u8a(0, 0, 0, 255)),
769            AnimValue::Color(Color::srgb_u8a(255, 128, 0, 255)),
770            Timing::SPRING_STANDARD,
771            start,
772        );
773        a.step(now_plus(start, 5));
774        a.settle();
775        match a.current {
776            AnimValue::Color(c) => {
777                assert_eq!(c.to_srgb_u8a(), [255, 128, 0, 255]);
778            }
779            _ => panic!("expected color"),
780        }
781        assert!(a.velocity.v.iter().all(|&v| v == 0.0));
782    }
783
784    #[test]
785    fn cubic_bezier_endpoints_pin() {
786        // Any curve must satisfy P(0)=0 and P(1)=1.
787        let p1 = (0.4, 0.0);
788        let p2 = (0.2, 1.0);
789        assert!((cubic_bezier_y_at_x(0.0, p1, p2) - 0.0).abs() < 1e-3);
790        assert!((cubic_bezier_y_at_x(1.0, p1, p2) - 1.0).abs() < 1e-3);
791    }
792
793    #[test]
794    fn color_channels_round_trip() {
795        // Channels are Oklab (L, a, b, alpha) so spring physics
796        // interpolates perceptually. Round trip via the same Color's
797        // space recovers the input to within float precision.
798        let c = Color::srgb_u8a(42, 17, 200, 255);
799        let v = AnimValue::Color(c);
800        let ch = v.channels();
801        assert_eq!(ch.n, 4);
802        let back = v.from_channels(ch);
803        let AnimValue::Color(back) = back else {
804            panic!("expected color");
805        };
806        let [r, g, b, a] = back.to_srgb_u8a();
807        assert_eq!(
808            [r, g, b, a],
809            [42, 17, 200, 255],
810            "round-trip should recover the source rgba within u8 precision"
811        );
812    }
813
814    #[test]
815    fn from_channels_drops_token_on_in_flight_eased_value() {
816        // An in-flight eased rgba is not the same color as the source
817        // token — keeping the token name on it would let palette
818        // resolution snap the rgb back to the source token's palette
819        // value, killing the transition. Spring/tween settled paths
820        // bypass `from_channels` and assign `self.current = self.target`
821        // directly, so settled values still carry the target's token.
822        let v = AnimValue::Color(Color::srgb_token("primary", 92, 170, 255, 255));
823        // Mid-flight: synthesize a halfway Oklab between the source and
824        // a different target. Channel semantics are Oklab (L, a, b, alpha).
825        let start = Color::srgb_u8(92, 170, 255).to_oklab();
826        let end = Color::srgb_u8(255, 100, 80).to_oklab();
827        let mid_lab = Oklab {
828            l: (start.l + end.l) * 0.5,
829            a: (start.a + end.a) * 0.5,
830            b: (start.b + end.b) * 0.5,
831            alpha: 1.0,
832        };
833        let mid = AnimChannels {
834            n: 4,
835            v: [mid_lab.l, mid_lab.a, mid_lab.b, mid_lab.alpha],
836        };
837        let eased = v.from_channels(mid);
838        match eased {
839            AnimValue::Color(c) => {
840                assert_eq!(c.token, None, "in-flight eased color must drop the token");
841                // The mid-flight value must lie strictly between start
842                // and end on each Oklab axis (perceptually mid).
843                let lab = c.to_oklab();
844                let lo_l = start.l.min(end.l);
845                let hi_l = start.l.max(end.l);
846                assert!(lab.l >= lo_l && lab.l <= hi_l, "L out of range");
847            }
848            _ => panic!("expected color"),
849        }
850    }
851}