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}