Skip to main content

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}