aetna_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
30use std::time::Duration;
31// web_time::Instant works on wasm32 (std::time::Instant::now() panics there).
32use web_time::Instant;
33
34use crate::tree::Color;
35
36pub mod tick;
37
38/// A value the animator can interpolate. Each variant fans out to a
39/// fixed number of f32 channels that the integrator steps independently.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum AnimValue {
42 Float(f32),
43 Color(Color),
44}
45
46impl AnimValue {
47 pub fn channels(self) -> AnimChannels {
48 match self {
49 AnimValue::Float(v) => AnimChannels {
50 n: 1,
51 v: [v, 0.0, 0.0, 0.0],
52 },
53 AnimValue::Color(c) => AnimChannels {
54 n: 4,
55 v: [c.r as f32, c.g as f32, c.b as f32, c.a as f32],
56 },
57 }
58 }
59
60 /// Reconstruct an `AnimValue` of the same variant from sampled
61 /// channels. The token name is dropped — an in-flight interpolated
62 /// rgba doesn't equal any palette token's rgb, so carrying a name
63 /// on it would mislead palette resolution. When the animation
64 /// settles, `step_spring` / `step_tween` assign
65 /// `self.current = self.target` directly, restoring the target's
66 /// token on the final value.
67 pub fn from_channels(self, ch: AnimChannels) -> AnimValue {
68 match self {
69 AnimValue::Float(_) => AnimValue::Float(ch.v[0]),
70 AnimValue::Color(_) => AnimValue::Color(Color {
71 r: ch.v[0].round().clamp(0.0, 255.0) as u8,
72 g: ch.v[1].round().clamp(0.0, 255.0) as u8,
73 b: ch.v[2].round().clamp(0.0, 255.0) as u8,
74 a: ch.v[3].round().clamp(0.0, 255.0) as u8,
75 token: None,
76 }),
77 }
78 }
79}
80
81#[derive(Clone, Copy, Debug)]
82pub struct AnimChannels {
83 pub n: usize,
84 pub v: [f32; 4],
85}
86
87impl AnimChannels {
88 pub fn zero(n: usize) -> Self {
89 Self { n, v: [0.0; 4] }
90 }
91}
92
93/// Spring physics configuration: mass-spring-damper.
94///
95/// The four preset constants are calibrated to feel competitive with
96/// modern native motion (UIKit defaults, Material 3 motion). Authors
97/// pick a preset; ad-hoc tuning is intentionally not exposed to keep
98/// the surface area small.
99#[derive(Clone, Copy, Debug)]
100pub struct SpringConfig {
101 pub mass: f32,
102 pub stiffness: f32,
103 pub damping: f32,
104}
105
106impl SpringConfig {
107 /// High stiffness, near-critical damping. ~150 ms settle, no
108 /// overshoot. Use for hover / focus where overshoot reads as jitter.
109 pub const QUICK: Self = Self {
110 mass: 1.0,
111 stiffness: 380.0,
112 damping: 30.0,
113 };
114 /// Balanced. ~250 ms settle, mild overshoot. Default state changes.
115 pub const STANDARD: Self = Self {
116 mass: 1.0,
117 stiffness: 200.0,
118 damping: 22.0,
119 };
120 /// Visible overshoot. Press-release rebound, playful interactions.
121 pub const BOUNCY: Self = Self {
122 mass: 1.0,
123 stiffness: 240.0,
124 damping: 14.0,
125 };
126 /// Soft, large displacements. Modal appearance, panel transitions.
127 pub const GENTLE: Self = Self {
128 mass: 1.0,
129 stiffness: 80.0,
130 damping: 18.0,
131 };
132}
133
134/// Cubic-bezier tween: P0=(0,0), P3=(1,1), with two control points.
135#[derive(Clone, Copy, Debug)]
136pub struct TweenConfig {
137 pub duration: Duration,
138 pub p1: (f32, f32),
139 pub p2: (f32, f32),
140}
141
142impl TweenConfig {
143 /// 100 ms ease-out. For micro-interactions where physics is overkill.
144 pub const EASE_QUICK: Self = Self {
145 duration: Duration::from_millis(100),
146 p1: (0.0, 0.0),
147 p2: (0.2, 1.0),
148 };
149 /// 200 ms ease-in-out. Symmetric default tween.
150 pub const EASE_STANDARD: Self = Self {
151 duration: Duration::from_millis(200),
152 p1: (0.4, 0.0),
153 p2: (0.2, 1.0),
154 };
155 /// 350 ms slow-out, fast-end. For larger displacements where the
156 /// final settle should feel decisive.
157 pub const EASE_EMPHASIZED: Self = Self {
158 duration: Duration::from_millis(350),
159 p1: (0.05, 0.7),
160 p2: (0.1, 1.0),
161 };
162}
163
164/// Choice of motion model for an animated property. Springs feel
165/// physical (continue from current+velocity on retarget); tweens feel
166/// curated (fixed curve, fixed duration).
167#[derive(Clone, Copy, Debug)]
168pub enum Timing {
169 Spring(SpringConfig),
170 Tween(TweenConfig),
171}
172
173impl Timing {
174 pub const SPRING_QUICK: Self = Timing::Spring(SpringConfig::QUICK);
175 pub const SPRING_STANDARD: Self = Timing::Spring(SpringConfig::STANDARD);
176 pub const SPRING_BOUNCY: Self = Timing::Spring(SpringConfig::BOUNCY);
177 pub const SPRING_GENTLE: Self = Timing::Spring(SpringConfig::GENTLE);
178 pub const EASE_QUICK: Self = Timing::Tween(TweenConfig::EASE_QUICK);
179 pub const EASE_STANDARD: Self = Timing::Tween(TweenConfig::EASE_STANDARD);
180 pub const EASE_EMPHASIZED: Self = Timing::Tween(TweenConfig::EASE_EMPHASIZED);
181}
182
183/// Identifies a specific animatable property on a node. Used as part
184/// of the per-(node, prop) tracker key.
185///
186/// Two families:
187///
188/// - **State envelopes** (`HoverAmount`, `PressAmount`, `FocusRingAlpha`)
189/// are 0..1 floats tracking *how much* of the corresponding state's
190/// visual delta is currently applied. The library updates these on
191/// every keyed interactive node automatically; no author opt-in. Why
192/// envelopes and not absolute colours: `apply_state` in `draw_ops`
193/// computes the display colour by lerping between `n.fill` and
194/// `state_color(n.fill)` based on the envelope. That keeps state
195/// easing completely independent of build-value changes — when the
196/// author swaps a button's fill mid-hover, the new fill takes effect
197/// instantly with the same hover envelope, no fighting between
198/// trackers.
199/// - **App-driven absolute values** (`App*`) are author-opted-in via
200/// [`crate::tree::El::animate`]. The tracker eases the value the build
201/// closure produces from the previous frame's value to the new one.
202#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
203#[non_exhaustive]
204pub enum AnimProp {
205 /// 0..1 amount of the hover-state visual delta currently applied.
206 /// Eases 0→1 on pointer enter, 1→0 on pointer leave.
207 HoverAmount,
208 /// 0..1 amount of the press-state visual delta currently applied.
209 /// Eases 0→1 on press, 1→0 on release.
210 PressAmount,
211 /// Focus-ring alpha — eases 0→1 on focus enter, 1→0 on focus leave.
212 /// Lets the ring fade out after focus moves elsewhere.
213 FocusRingAlpha,
214 /// 0..1 amount tracking "is the hover target this node or any
215 /// descendant?". Eases 0→1 when the cursor enters the subtree, 1→0
216 /// when it leaves. Drives region-shaped hover affordances
217 /// (`hover_alpha`, future hover-driven translate / scale / tint).
218 SubtreeHoverAmount,
219 /// 0..1 amount tracking "is the press target this node or any
220 /// descendant?". Subtree analogue of `PressAmount`.
221 SubtreePressAmount,
222 /// 0..1 amount tracking "is the focus target this node or any
223 /// descendant?". Subtree analogue of `FocusRingAlpha`. Composed
224 /// with `SubtreeHoverAmount` by `hover_alpha` so keyboard focus
225 /// reveals the same affordance hover does.
226 SubtreeFocusAmount,
227 /// App-driven fill colour — eases between the values the build
228 /// closure produces across rebuilds.
229 AppFill,
230 /// App-driven stroke colour.
231 AppStroke,
232 /// App-driven text colour.
233 AppTextColor,
234 /// App-driven paint-time alpha multiplier in `[0, 1]`.
235 AppOpacity,
236 /// App-driven uniform scale around the rect centre.
237 AppScale,
238 /// App-driven translate offset in logical pixels — X channel.
239 AppTranslateX,
240 /// App-driven translate offset in logical pixels — Y channel.
241 AppTranslateY,
242}
243
244const SPRING_EPSILON_DISP: f32 = 0.5;
245const SPRING_EPSILON_VEL: f32 = 0.5;
246const DT_CAP: f32 = 0.064;
247/// Hard upper bound on the per-substep timestep used inside `step_spring`.
248/// The semi-implicit Euler scheme with explicit damping is stable for
249/// `dt < 2·sqrt(m/k) + small damping correction`; the stiffest preset
250/// (`SpringConfig::QUICK`, k=380, c=30) has a stability bound near 58 ms.
251/// `DT_CAP` (64 ms) sits above that, so without substepping the integrator
252/// can blow up after long idle pauses or on slow frames — `current`
253/// overshoots into ±values and the 0..1 envelope `clamp` rounds to a
254/// binary flicker. 4 ms keeps every preset comfortably stable.
255const SPRING_MAX_SUBSTEP: f32 = 1.0 / 250.0;
256
257/// In-flight animation state for one (node, prop) pair. Stored on
258/// [`crate::state::UiState`] keyed by `(ComputedId, AnimProp)`.
259#[derive(Clone, Debug)]
260#[non_exhaustive]
261pub struct Animation {
262 pub current: AnimValue,
263 pub target: AnimValue,
264 pub velocity: AnimChannels,
265 pub timing: Timing,
266 pub started_at: Instant,
267 pub last_step: Instant,
268 /// For tweens, the value at `started_at`. Springs are fully
269 /// determined by current+velocity, so `from` stays `None`.
270 pub from: Option<AnimValue>,
271}
272
273impl Animation {
274 pub fn new(current: AnimValue, target: AnimValue, timing: Timing, now: Instant) -> Self {
275 let n = current.channels().n;
276 let from = match timing {
277 Timing::Tween(_) => Some(current),
278 Timing::Spring(_) => None,
279 };
280 Self {
281 current,
282 target,
283 velocity: AnimChannels::zero(n),
284 timing,
285 started_at: now,
286 last_step: now,
287 from,
288 }
289 }
290
291 /// Re-target a running animation. Current value and velocity carry
292 /// over so interrupted motion eases from where it is, not from rest.
293 /// For tweens, `from` snaps to the current sample so the new curve
294 /// starts there; the tween clock resets.
295 pub fn retarget(&mut self, target: AnimValue, now: Instant) {
296 if same_value(self.target, target) {
297 return;
298 }
299 self.target = target;
300 if matches!(self.timing, Timing::Tween(_)) {
301 self.from = Some(self.current);
302 self.started_at = now;
303 }
304 // Springs: keep current+velocity untouched. The integrator now
305 // sees a different `target` and forces will steer toward it.
306 }
307
308 /// Snap to target and zero velocity. Used by the headless bundle
309 /// path so SVG/PNG fixtures don't depend on integrator timing.
310 pub fn settle(&mut self) {
311 self.current = self.target;
312 let n = self.current.channels().n;
313 self.velocity = AnimChannels::zero(n);
314 self.from = None;
315 }
316
317 /// Step the animation forward to `now`. Returns `true` if settled.
318 pub fn step(&mut self, now: Instant) -> bool {
319 let dt = now
320 .saturating_duration_since(self.last_step)
321 .as_secs_f32()
322 .min(DT_CAP);
323 self.last_step = now;
324 match self.timing {
325 Timing::Spring(cfg) => self.step_spring(cfg, dt),
326 Timing::Tween(cfg) => self.step_tween(cfg, now),
327 }
328 }
329
330 fn step_spring(&mut self, cfg: SpringConfig, dt: f32) -> bool {
331 if dt <= 0.0 {
332 return self.is_settled();
333 }
334 let mut cur = self.current.channels();
335 let tgt = self.target.channels();
336 let mut vel = if self.velocity.n == cur.n {
337 self.velocity
338 } else {
339 AnimChannels::zero(cur.n)
340 };
341 // Substep so each integrator step is well within the stability
342 // bound for every SpringConfig preset. A single h = `dt` step
343 // would diverge for stiff presets when frames stall or the host
344 // resumes after a long idle (dt clamped to DT_CAP > stability
345 // bound for QUICK), producing binary 0/1 flicker once `current`
346 // overshoots into ±range and write_prop's clamp rounds it.
347 let n_steps = (dt / SPRING_MAX_SUBSTEP).ceil().max(1.0) as usize;
348 let h = dt / n_steps as f32;
349 let mut all_settled = false;
350 for _ in 0..n_steps {
351 all_settled = true;
352 for i in 0..cur.n {
353 let displacement = cur.v[i] - tgt.v[i];
354 let force = -cfg.stiffness * displacement - cfg.damping * vel.v[i];
355 // Semi-implicit Euler: update velocity first, then position
356 // using the new velocity. More stable than fully explicit
357 // for stiff systems within UI's typical stiffness range.
358 vel.v[i] += (force / cfg.mass) * h;
359 cur.v[i] += vel.v[i] * h;
360 if displacement.abs() > SPRING_EPSILON_DISP || vel.v[i].abs() > SPRING_EPSILON_VEL {
361 all_settled = false;
362 }
363 }
364 if all_settled {
365 break;
366 }
367 }
368 if all_settled {
369 self.current = self.target;
370 self.velocity = AnimChannels::zero(cur.n);
371 return true;
372 }
373 self.current = self.current.from_channels(cur);
374 self.velocity = vel;
375 false
376 }
377
378 fn step_tween(&mut self, cfg: TweenConfig, now: Instant) -> bool {
379 let elapsed = now.saturating_duration_since(self.started_at);
380 if elapsed >= cfg.duration {
381 self.current = self.target;
382 return true;
383 }
384 let from = self.from.unwrap_or(self.current).channels();
385 let tgt = self.target.channels();
386 let t = elapsed.as_secs_f32() / cfg.duration.as_secs_f32();
387 let eased = cubic_bezier_y_at_x(t, cfg.p1, cfg.p2);
388 let mut next = AnimChannels {
389 n: from.n,
390 v: [0.0; 4],
391 };
392 for i in 0..from.n {
393 next.v[i] = from.v[i] + (tgt.v[i] - from.v[i]) * eased;
394 }
395 self.current = self.current.from_channels(next);
396 false
397 }
398
399 fn is_settled(&self) -> bool {
400 same_value(self.current, self.target)
401 && (0..self.velocity.n).all(|i| self.velocity.v[i].abs() <= SPRING_EPSILON_VEL)
402 }
403}
404
405fn same_value(a: AnimValue, b: AnimValue) -> bool {
406 let ca = a.channels();
407 let cb = b.channels();
408 if ca.n != cb.n {
409 return false;
410 }
411 (0..ca.n).all(|i| (ca.v[i] - cb.v[i]).abs() < f32::EPSILON)
412}
413
414/// Solve `cubic_bezier(t).x == x` for `t`, then return `cubic_bezier(t).y`.
415/// P0=(0,0), P3=(1,1). Newton-Raphson with binary-search fallback.
416fn cubic_bezier_y_at_x(x: f32, p1: (f32, f32), p2: (f32, f32)) -> f32 {
417 if x <= 0.0 {
418 return 0.0;
419 }
420 if x >= 1.0 {
421 return 1.0;
422 }
423 // Newton-Raphson on x(t) — converges in 4-6 iterations for typical
424 // ease curves. Fall back to bisection if the derivative collapses.
425 let mut t = x;
426 for _ in 0..8 {
427 let xt = bezier_axis(t, p1.0, p2.0);
428 let dx = bezier_axis_derivative(t, p1.0, p2.0);
429 if dx.abs() < 1e-6 {
430 break;
431 }
432 let next = t - (xt - x) / dx;
433 if (next - t).abs() < 1e-5 {
434 t = next.clamp(0.0, 1.0);
435 break;
436 }
437 t = next.clamp(0.0, 1.0);
438 }
439 bezier_axis(t, p1.1, p2.1)
440}
441
442/// Cubic Bezier polynomial: B(t) = 3·(1-t)²·t·c1 + 3·(1-t)·t²·c2 + t³.
443/// P0 and P3 are pinned at 0 and 1 (no contribution beyond the t³ term).
444fn bezier_axis(t: f32, c1: f32, c2: f32) -> f32 {
445 let one_minus_t = 1.0 - t;
446 3.0 * one_minus_t * one_minus_t * t * c1 + 3.0 * one_minus_t * t * t * c2 + t * t * t
447}
448
449fn bezier_axis_derivative(t: f32, c1: f32, c2: f32) -> f32 {
450 let one_minus_t = 1.0 - t;
451 3.0 * one_minus_t * one_minus_t * c1
452 + 6.0 * one_minus_t * t * (c2 - c1)
453 + 3.0 * t * t * (1.0 - c2)
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn now_plus(start: Instant, ms: u64) -> Instant {
461 start + Duration::from_millis(ms)
462 }
463
464 #[test]
465 fn spring_settles_to_target() {
466 let start = Instant::now();
467 let mut a = Animation::new(
468 AnimValue::Float(0.0),
469 AnimValue::Float(1.0),
470 Timing::SPRING_QUICK,
471 start,
472 );
473 let mut t = start;
474 for _ in 0..200 {
475 t += Duration::from_millis(8);
476 if a.step(t) {
477 break;
478 }
479 }
480 let AnimValue::Float(v) = a.current else {
481 panic!("expected float")
482 };
483 assert!((v - 1.0).abs() < 1e-3, "spring did not settle: v={v}");
484 }
485
486 #[test]
487 fn spring_retarget_preserves_velocity() {
488 // Start moving 0 → 1; mid-flight retarget back to 0 should
489 // briefly continue past the new target before reversing —
490 // momentum carries.
491 let start = Instant::now();
492 let mut a = Animation::new(
493 AnimValue::Float(0.0),
494 AnimValue::Float(1.0),
495 Timing::SPRING_STANDARD,
496 start,
497 );
498 let mut t = start;
499 for _ in 0..15 {
500 t += Duration::from_millis(8);
501 a.step(t);
502 }
503 let mid = match a.current {
504 AnimValue::Float(v) => v,
505 _ => unreachable!(),
506 };
507 assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}");
508 let velocity_before = a.velocity.v[0];
509 assert!(velocity_before > 0.0);
510 a.retarget(AnimValue::Float(0.0), t);
511 // Velocity is preserved — the spring will continue forward briefly.
512 assert_eq!(a.velocity.v[0], velocity_before);
513 }
514
515 #[test]
516 fn tween_samples_endpoints() {
517 let start = Instant::now();
518 let mut a = Animation::new(
519 AnimValue::Float(10.0),
520 AnimValue::Float(20.0),
521 Timing::EASE_STANDARD,
522 start,
523 );
524 a.step(start);
525 let AnimValue::Float(v0) = a.current else {
526 panic!()
527 };
528 assert!(
529 (v0 - 10.0).abs() < 1e-3,
530 "tween at t=0 should equal `from`, got {v0}"
531 );
532
533 a.step(now_plus(start, 1000));
534 let AnimValue::Float(vend) = a.current else {
535 panic!()
536 };
537 assert!(
538 (vend - 20.0).abs() < 1e-3,
539 "tween past duration should equal target, got {vend}"
540 );
541 }
542
543 #[test]
544 fn tween_retarget_snaps_from_to_current() {
545 let start = Instant::now();
546 let mut a = Animation::new(
547 AnimValue::Float(0.0),
548 AnimValue::Float(100.0),
549 Timing::EASE_STANDARD,
550 start,
551 );
552 a.step(now_plus(start, 100));
553 let AnimValue::Float(mid) = a.current else {
554 panic!()
555 };
556 a.retarget(AnimValue::Float(0.0), now_plus(start, 100));
557 assert_eq!(a.from, Some(AnimValue::Float(mid)));
558 }
559
560 #[test]
561 fn settle_snaps_to_target() {
562 let start = Instant::now();
563 let mut a = Animation::new(
564 AnimValue::Color(Color::rgba(0, 0, 0, 255)),
565 AnimValue::Color(Color::rgba(255, 128, 0, 255)),
566 Timing::SPRING_STANDARD,
567 start,
568 );
569 a.step(now_plus(start, 5));
570 a.settle();
571 match a.current {
572 AnimValue::Color(c) => {
573 assert_eq!((c.r, c.g, c.b, c.a), (255, 128, 0, 255));
574 }
575 _ => panic!("expected color"),
576 }
577 assert!(a.velocity.v.iter().all(|&v| v == 0.0));
578 }
579
580 #[test]
581 fn cubic_bezier_endpoints_pin() {
582 // Any curve must satisfy P(0)=0 and P(1)=1.
583 let p1 = (0.4, 0.0);
584 let p2 = (0.2, 1.0);
585 assert!((cubic_bezier_y_at_x(0.0, p1, p2) - 0.0).abs() < 1e-3);
586 assert!((cubic_bezier_y_at_x(1.0, p1, p2) - 1.0).abs() < 1e-3);
587 }
588
589 #[test]
590 fn color_channels_round_trip() {
591 let c = Color::rgba(42, 17, 200, 255);
592 let v = AnimValue::Color(c);
593 let ch = v.channels();
594 assert_eq!(ch.n, 4);
595 assert_eq!(ch.v, [42.0, 17.0, 200.0, 255.0]);
596 let back = v.from_channels(ch);
597 assert_eq!(back, AnimValue::Color(c));
598 }
599
600 #[test]
601 fn from_channels_drops_token_on_in_flight_eased_value() {
602 // An in-flight eased rgba is not the same color as the source
603 // token — keeping the token name on it would let palette
604 // resolution snap the rgb back to the source token's palette
605 // value, killing the transition. Spring/tween settled paths
606 // bypass `from_channels` and assign `self.current = self.target`
607 // directly, so settled values still carry the target's token.
608 let v = AnimValue::Color(Color::token("primary", 92, 170, 255, 255));
609 let mid = AnimChannels {
610 n: 4,
611 v: [128.0, 100.0, 80.0, 255.0],
612 };
613 let eased = v.from_channels(mid);
614 match eased {
615 AnimValue::Color(c) => {
616 assert_eq!(c.token, None, "in-flight eased color must drop the token");
617 assert_eq!((c.r, c.g, c.b), (128, 100, 80));
618 }
619 _ => panic!("expected color"),
620 }
621 }
622}