Skip to main content

kui_core/
anim.rs

1//! Transitions: retained tweens keyed by node identity.
2//!
3//! A node that declares [`NodeSpec::transition`](crate::spec::NodeSpec::transition)
4//! has its animatable spec values (sizing amounts, colours, radius, opacity,
5//! shadow) eased from whatever they were last frame toward what the view
6//! declares this frame. The *inputs* to layout animate, so a subtree lays
7//! out consistently every frame instead of children snapping to a target
8//! size inside a still-moving parent. The view keeps declaring the target;
9//! nothing else is needed.
10//!
11//! ```rust
12//! use kui_core::{Easing, NodeSpec, Sizing, Transition};
13//!
14//! // A sidebar whose width eases over 200 ms, keyed so the tween survives.
15//! let open = true;
16//! let w = if open { 240.0 } else { 48.0 };
17//! let sidebar = NodeSpec::column().width(Sizing::Fixed(w)).transition(200.0);
18//!
19//! // A spring instead of a curve, with a little overshoot.
20//! let springy = Transition::ms(350.0).easing(Easing::Spring).bounce(0.3);
21//! let card = NodeSpec::row().transition_with(springy).slide();
22//!
23//! assert_eq!(sidebar.transition.unwrap().easing, Easing::EaseOut);
24//! assert!(card.transition.unwrap().curve().is_spring() && card.slide);
25//! ```
26//!
27//! Two kinds of motion: timed curves ([`Easing::EaseOut`] and friends,
28//! which replay a leg from wherever the value was over `duration_ms`) and
29//! springs ([`Easing::Smooth`], [`Easing::Snappy`], [`Easing::Spring`],
30//! [`Easing::Bouncy`]), integrated per frame with a velocity that survives
31//! retargets — a value chased mid-flight keeps its momentum instead of
32//! restarting, which is what dragged and reordered things want.
33//!
34//! A spring takes the two numbers a person tunes by eye, not the physics:
35//! `duration_ms`, how long it takes to get there (the response time), and
36//! a [`Bounce`], how far it overshoots — 0 glides in, 0.5 bounces. Each
37//! spring easing is a named bounce, and [`Transition::bounce`] sets any
38//! other; the stiffness and damping follow from the two.
39//!
40//! A node can also declare [`crate::NodeSpec::keyframes`]: CSS-style
41//! stops for any of the same slots, cycled over `duration_ms` in one of
42//! CSS's four directions ([`Repeat`]). A keyframed slot is sampled straight
43//! off the clock instead of retained as a tween, so nothing drifts, siblings
44//! offset by `delay_ms` stay in phase with each other, and a view that
45//! declares a pulse never has to wake up to flip a target.
46//!
47//! The core stays clock-free: the frame driver injects the time with
48//! [`crate::Core::set_time`]. A driver that never does (headless tests, a C
49//! host without a clock) sees every transition snap to its target.
50
51use rustc_hash::FxHashMap;
52
53use crate::key::Key;
54
55/// Easing curve for a [`Transition`].
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Easing {
58    /// Cubic ease-out: fast start, gentle landing (the UI default).
59    #[default]
60    EaseOut,
61    Linear,
62    EaseIn,
63    EaseInOut,
64    /// A spring with a hint of overshoot (bounce 0.25). `duration_ms` is
65    /// how long it takes to get there; velocity carries across retargets.
66    Spring,
67    /// A spring that visibly bounces (bounce 0.5).
68    Bouncy,
69    /// A spring that glides in without overshooting (bounce 0): an
70    /// ease-out that keeps its momentum when retargeted.
71    Smooth,
72    /// A quick spring with a trace of overshoot (bounce 0.15).
73    Snappy,
74}
75
76impl Easing {
77    /// Every curve, in wire order: `schema::EASINGS` is `ALL` by `name`,
78    /// and a binding sends the index.
79    pub const ALL: &'static [Easing] = &[
80        Easing::EaseOut,
81        Easing::Linear,
82        Easing::EaseIn,
83        Easing::EaseInOut,
84        Easing::Spring,
85        Easing::Bouncy,
86        Easing::Smooth,
87        Easing::Snappy,
88    ];
89
90    /// The camelCase spelling every binding uses.
91    pub fn name(self) -> &'static str {
92        match self {
93            Easing::EaseOut => "easeOut",
94            Easing::Linear => "linear",
95            Easing::EaseIn => "easeIn",
96            Easing::EaseInOut => "easeInOut",
97            Easing::Spring => "spring",
98            Easing::Bouncy => "bouncy",
99            Easing::Smooth => "smooth",
100            Easing::Snappy => "snappy",
101        }
102    }
103
104    /// The variant `schema::EASINGS` index `i` names; the default for an
105    /// index this build lacks.
106    pub fn from_index(i: usize) -> Easing {
107        Self::ALL.get(i).copied().unwrap_or_default()
108    }
109
110    /// The bounce a spring easing has unless [`Transition::bounce`] says
111    /// otherwise; None for the timed curves.
112    pub fn bounce(self) -> Option<f32> {
113        match self {
114            Easing::Smooth => Some(0.0),
115            Easing::Snappy => Some(0.15),
116            Easing::Spring => Some(0.25),
117            Easing::Bouncy => Some(0.5),
118            Easing::EaseOut | Easing::Linear | Easing::EaseIn | Easing::EaseInOut => None,
119        }
120    }
121
122    /// Whether this is a spring, integrated with momentum, rather than a
123    /// timed curve.
124    pub fn is_spring(self) -> bool {
125        self.bounce().is_some()
126    }
127
128    pub fn apply(self, t: f32) -> f32 {
129        let t = t.clamp(0.0, 1.0);
130        match self {
131            Easing::Linear => t,
132            Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
133            Easing::EaseIn => t * t * t,
134            Easing::EaseInOut => {
135                if t < 0.5 {
136                    4.0 * t * t * t
137                } else {
138                    1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
139                }
140            }
141            // Springs are integrated, not sampled; as a curve, ease out.
142            Easing::Spring | Easing::Bouncy | Easing::Smooth | Easing::Snappy => {
143                1.0 - (1.0 - t).powi(3)
144            }
145        }
146    }
147}
148
149/// How keyframes cycle: CSS's `animation-direction`, always infinite.
150/// Between two stops the transition's [`Easing::apply`] curve is used, so
151/// a spring there is `EaseOut` and its bounce unread (backlog F141).
152#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
153pub enum Repeat {
154    /// Forward, then jump back and replay.
155    #[default]
156    Normal,
157    /// Backward, then jump forward and replay.
158    Reverse,
159    /// Forward, then backward, forever (the easing reverses with it).
160    Alternate,
161    /// Backward first, then forward.
162    AlternateReverse,
163}
164
165impl Repeat {
166    /// Every direction, in wire order: `schema::REPEATS` is `ALL` by
167    /// `name`.
168    pub const ALL: &'static [Repeat] = &[
169        Repeat::Normal,
170        Repeat::Reverse,
171        Repeat::Alternate,
172        Repeat::AlternateReverse,
173    ];
174
175    /// CSS's `animation-direction` spelling, camelCased.
176    pub fn name(self) -> &'static str {
177        match self {
178            Repeat::Normal => "normal",
179            Repeat::Reverse => "reverse",
180            Repeat::Alternate => "alternate",
181            Repeat::AlternateReverse => "alternateReverse",
182        }
183    }
184
185    /// The variant `schema::REPEATS` index `i` names; the default for an
186    /// index this build lacks.
187    pub fn from_index(i: usize) -> Repeat {
188        Self::ALL.get(i).copied().unwrap_or_default()
189    }
190
191    /// Where in the keyframe cycle (0..=1) a clock reading lands, with `u`
192    /// in periods.
193    fn progress(self, u: f64) -> f32 {
194        // `rem_euclid` so a delay past the clock's origin still lands in
195        // the cycle rather than running it backwards.
196        let p = match self {
197            Repeat::Normal => u.rem_euclid(1.0),
198            Repeat::Reverse => 1.0 - u.rem_euclid(1.0),
199            Repeat::Alternate => {
200                let c = u.rem_euclid(2.0);
201                if c < 1.0 { c } else { 2.0 - c }
202            }
203            Repeat::AlternateReverse => {
204                let c = u.rem_euclid(2.0);
205                if c < 1.0 { 1.0 - c } else { c - 1.0 }
206            }
207        };
208        p as f32
209    }
210
211    /// Whether iteration `k` (from 0) runs its stops first to last.
212    fn forward(self, k: u64) -> bool {
213        match self {
214            Repeat::Normal => true,
215            Repeat::Reverse => false,
216            Repeat::Alternate => k.is_multiple_of(2),
217            Repeat::AlternateReverse => !k.is_multiple_of(2),
218        }
219    }
220
221    /// Where a cycle of `n` iterations (backlog F133) stands once it is
222    /// over: the end of its last iteration, which a fraction of one cuts
223    /// short — CSS's `animation-fill-mode: forwards`. An alternate cycle
224    /// of two ends where it began.
225    fn end(self, n: f64) -> f32 {
226        let k = (n.ceil() as u64).saturating_sub(1);
227        let frac = (n - k as f64).clamp(0.0, 1.0) as f32;
228        if self.forward(k) { frac } else { 1.0 - frac }
229    }
230
231    /// Where a cycle stands before it begins, during its `delay`: the
232    /// start of its first iteration — CSS's `backwards` fill, so a
233    /// staggered one-shot does not show the node's own value first.
234    fn start(self) -> f32 {
235        if self.forward(0) { 0.0 } else { 1.0 }
236    }
237}
238
239/// Spring integration step (seconds); a frame is split into steps this
240/// long so a stiff spring stays stable at any frame rate.
241const SPRING_STEP: f64 = 0.004;
242/// A pause longer than this (window hidden, debugger) doesn't get replayed
243/// as one giant step.
244const MAX_FRAME_DT: f64 = 0.1;
245
246/// The most bounce a spring takes: at 1 it would never settle, and past
247/// 0.9 it rings for seconds.
248pub const MAX_BOUNCE: f32 = 0.9;
249
250/// How far a spring overshoots, 0 (glides in, no overshoot) to
251/// [`MAX_BOUNCE`] (rings a while): the one number that shapes a spring
252/// besides its duration. A bounce `b` is a damping ratio of `1 - b`
253/// (SwiftUI's `Spring(duration:bounce:)`).
254///
255/// Held in ten-thousandths in two bytes, because it rides in
256/// [`Transition`], which rides inline in every `NodeSpec` — and the two
257/// bytes of padding `Transition` had spare are all the room there is
258/// (`node_spec_stays_small`). The niche keeps `Option<Bounce>` at two.
259#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
260pub struct Bounce(std::num::NonZeroU16);
261
262impl Bounce {
263    /// `b` clamped to 0..=[`MAX_BOUNCE`]; NaN reads as 0.
264    pub fn new(b: f32) -> Self {
265        let q = (b.clamp(0.0, MAX_BOUNCE) * 10_000.0).round() as u16;
266        Bounce(std::num::NonZeroU16::MIN.saturating_add(q))
267    }
268
269    pub fn get(self) -> f32 {
270        (self.0.get() - 1) as f32 / 10_000.0
271    }
272}
273
274/// How a node's animatable values move when the view changes them: a
275/// duration, an easing (a timed curve or a spring), and for keyframes a
276/// repeat direction and a delay. Built with [`Transition::ms`] and the
277/// builders, or through the `NodeSpec` shorthands (`transition`, `easing`,
278/// `bounce`, `repeat`, `delay`).
279#[derive(Clone, Copy, Debug, PartialEq)]
280pub struct Transition {
281    /// How long a timed curve takes; for a spring, its response time —
282    /// about how long it takes to get there.
283    pub duration_ms: f32,
284    pub easing: Easing,
285    /// How the node's keyframes cycle (nothing without keyframes).
286    pub repeat: Repeat,
287    /// A spring's bounce in place of its easing's own; on a timed curve
288    /// it makes the transition a spring ([`Self::curve`]). None is the
289    /// easing's.
290    pub bounce: Option<Bounce>,
291    /// Holds the keyframe cycle back by this many ms, so siblings given
292    /// different delays run out of phase (CSS's `animation-delay`).
293    pub delay_ms: f32,
294}
295
296impl Transition {
297    /// A cubic ease-out over `duration_ms`.
298    pub fn ms(duration_ms: f32) -> Self {
299        Self {
300            duration_ms,
301            easing: Easing::EaseOut,
302            repeat: Repeat::Normal,
303            bounce: None,
304            delay_ms: 0.0,
305        }
306    }
307
308    /// The curve or spring to move by.
309    pub fn easing(mut self, easing: Easing) -> Self {
310        self.easing = easing;
311        self
312    }
313
314    /// How keyframes cycle (CSS's `animation-direction`).
315    pub fn repeat(mut self, repeat: Repeat) -> Self {
316        self.repeat = repeat;
317        self
318    }
319
320    /// Holds a keyframe cycle back by `delay_ms` (CSS's `animation-delay`).
321    pub fn delay(mut self, delay_ms: f32) -> Self {
322        self.delay_ms = delay_ms;
323        self
324    }
325
326    /// Springs with this bounce, 0 (no overshoot) to [`MAX_BOUNCE`]: on a
327    /// spring easing in place of its own, and on a timed one in place of
328    /// the curve, since a bounce is only a spring's to have.
329    pub fn bounce(mut self, bounce: f32) -> Self {
330        self.bounce = Some(Bounce::new(bounce));
331        self
332    }
333
334    /// The easing this transition moves by: its `easing`, or
335    /// [`Easing::Spring`] when a bounce was given to a timed curve.
336    pub fn curve(&self) -> Easing {
337        match self.bounce {
338            Some(_) if !self.easing.is_spring() => Easing::Spring,
339            _ => self.easing,
340        }
341    }
342
343    /// The damping ratio when this transition is a spring (`1 - bounce`);
344    /// None for a timed curve.
345    fn damping(&self) -> Option<f32> {
346        let own = self.curve().bounce()?;
347        Some(1.0 - self.bounce.map_or(own, Bounce::get))
348    }
349}
350
351/// A keyframed slot: `(offset, value)` stops with offsets ascending from 0
352/// to 1 (see [`crate::keyframes`], which builds them).
353pub(crate) type Track = [(f32, [f32; 4])];
354
355/// Which spec value a tween drives. One node can animate several at once.
356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357#[repr(u8)]
358pub(crate) enum Slot {
359    Width = 0,
360    Height = 1,
361    Bg = 2,
362    Border = 3,
363    Radius = 4,
364    Pos = 5,
365    Opacity = 6,
366    /// The shadow's geometry as one vector: dx, dy, blur, spread.
367    Shadow = 7,
368    ShadowColor = 8,
369    /// The node's turn in turns and its scale, as one vector: rotate,
370    /// scale, two spare (ADR 0043). Not one of the [`SLOTS`] every
371    /// transitioning node carries: retained beside them, in
372    /// `AnimStore::turns`, for the nodes that turn.
373    Transform = 9,
374}
375
376/// The slots retained per transitioning node, in a fixed array: every slot
377/// but [`Slot::Transform`]. A slot here is 96 bytes on every node that
378/// declares a `transition`, whether or not it ever drives that slot, and
379/// the tenth cost a frame of 10,000 transitioning boxes 2.6% when it was
380/// one of them (ADR 0043's amendment).
381const SLOTS: usize = 9;
382
383impl Slot {
384    /// Every slot, at its index: the [`SLOTS`] retained in the array, then
385    /// the transform.
386    const ALL: [Slot; SLOTS + 1] = [
387        Slot::Width,
388        Slot::Height,
389        Slot::Bg,
390        Slot::Border,
391        Slot::Radius,
392        Slot::Pos,
393        Slot::Opacity,
394        Slot::Shadow,
395        Slot::ShadowColor,
396        Slot::Transform,
397    ];
398
399    /// The names of the slots set in `mask` (bit `slot as u16`), in slot
400    /// order.
401    pub(crate) fn names(mask: u16) -> Vec<&'static str> {
402        Self::ALL
403            .into_iter()
404            .filter(|s| mask & (1 << *s as u16) != 0)
405            .map(Slot::name)
406            .collect()
407    }
408
409    /// The prop the slot eases, as a person reads it in a trace
410    /// ([`crate::runtime::cause::FrameHolder::slots`]).
411    pub(crate) fn name(self) -> &'static str {
412        match self {
413            Slot::Width => "width",
414            Slot::Height => "height",
415            Slot::Bg => "bg",
416            Slot::Border => "borderColor",
417            Slot::Radius => "radius",
418            Slot::Pos => "position",
419            Slot::Opacity => "opacity",
420            Slot::Shadow => "shadow",
421            Slot::ShadowColor => "shadowColor",
422            Slot::Transform => "transform",
423        }
424    }
425}
426
427/// One slot's retained motion. Ten of these per transitioning node, held
428/// across frames, so what is *not* on it matters: a 10,000-node frame with
429/// a transition on every node walks the lot of them.
430///
431/// Two fields of the [`Transition`] and not the transition itself. A leg
432/// keeps the curve it started under — that is what makes retargeting a
433/// running tween continuous, and why this cannot be one value on the node
434/// — but a leg only ever reads `duration_ms` and `easing`. `repeat` and
435/// `delay_ms` belong to the keyframe cycle, which is sampled straight off
436/// the clock ([`sample_track`]) and never reaches a `Tween` at all. Both
437/// were being copied into every slot of every node to be read by nobody.
438///
439/// `last_used` stays per slot, and is not the same redundancy: a node does
440/// not drive all nine every frame — `Core::ease_positions` drives
441/// `Slot::Pos` on its own, and a slot the node's keyframes name is sampled
442/// instead of driven — so staleness, which is what makes a skipped slot
443/// snap rather than resume, is per slot too.
444#[derive(Clone, Copy, Debug)]
445struct Tween {
446    from: [f32; 4],
447    to: [f32; 4],
448    value: [f32; 4],
449    /// Time the current leg started, in the driver's seconds.
450    start: f64,
451    /// Springs: velocity per component and the time last integrated to.
452    velocity: [f32; 4],
453    last_time: f64,
454    /// The leg's own curve: `Transition::duration_ms` and `easing` as they
455    /// were when it started.
456    duration_ms: f32,
457    easing: Easing,
458    last_used: u64,
459}
460
461impl Tween {
462    /// A tween at rest on `value`: a leg that began at `start` — `0.0`
463    /// with no clock, infinitely long ago with one, so nothing is owed
464    /// until a retarget.
465    fn settled(value: [f32; 4], start: f64, last_time: f64, t: Transition, frame_no: u64) -> Self {
466        Tween {
467            from: value,
468            to: value,
469            value,
470            start,
471            velocity: [0.0; 4],
472            last_time,
473            duration_ms: t.duration_ms,
474            easing: t.curve(),
475            last_used: frame_no,
476        }
477    }
478
479    /// A leg from `from` to `to`, begun `now` on the transition's curve.
480    fn leg(from: [f32; 4], to: [f32; 4], now: f64, t: Transition, frame_no: u64) -> Self {
481        Tween {
482            from,
483            to,
484            value: from,
485            start: now,
486            velocity: [0.0; 4],
487            last_time: now,
488            duration_ms: t.duration_ms,
489            easing: t.curve(),
490            last_used: frame_no,
491        }
492    }
493
494    /// How far along its leg the tween is at `now`, 0..=1: complete when
495    /// the leg has no duration or began infinitely long ago.
496    #[inline]
497    fn progress_at(&self, now: f64) -> f32 {
498        let dur = self.duration_ms.max(0.0) as f64 / 1000.0;
499        if dur <= 0.0 {
500            1.0
501        } else {
502            (((now - self.start) / dur) as f32).clamp(0.0, 1.0)
503        }
504    }
505
506    /// The leg's value at progress `p`, eased.
507    #[inline]
508    fn at(&self, p: f32) -> [f32; 4] {
509        if p >= 1.0 {
510            return self.to;
511        }
512        let e = self.easing.apply(p);
513        std::array::from_fn(|i| self.from[i] + (self.to[i] - self.from[i]) * e)
514    }
515
516    /// Where this tween's current leg stands at `now`, without touching it.
517    /// A settled leg (`start` infinitely far back) reads as its target.
518    fn eased_at(&self, now: f64) -> [f32; 4] {
519        self.at(self.progress_at(now))
520    }
521
522    /// Advances a spring toward `to` from `last_time` to `now`; returns
523    /// whether it is still moving. Semi-implicit Euler on a unit-mass
524    /// spring with stiffness and damping from the response time and ratio
525    /// (SwiftUI's parametrization).
526    fn spring_step(&mut self, now: f64, zeta: f32) -> bool {
527        let response = (self.duration_ms.max(1.0) / 1000.0) as f64;
528        let omega = std::f64::consts::TAU / response;
529        let k = (omega * omega) as f32;
530        let c = (2.0 * zeta as f64 * omega) as f32;
531        let dt = (now - self.last_time).clamp(0.0, MAX_FRAME_DT);
532        self.last_time = now;
533        let steps = (dt / SPRING_STEP).ceil().max(1.0);
534        let h = (dt / steps) as f32;
535        for _ in 0..steps as usize {
536            for i in 0..4 {
537                let a = -k * (self.value[i] - self.to[i]) - c * self.velocity[i];
538                self.velocity[i] += a * h;
539                self.value[i] += self.velocity[i] * h;
540            }
541        }
542        // Settled: within a hair of the target and nearly still (units are
543        // px or 0..1 color channels; per second for velocity).
544        let moving = (0..4)
545            .any(|i| (self.value[i] - self.to[i]).abs() > 1e-3 || self.velocity[i].abs() > 5e-2);
546        if !moving {
547            self.value = self.to;
548            self.velocity = [0.0; 4];
549        }
550        moving
551    }
552}
553
554#[derive(Default)]
555pub struct AnimStore {
556    tweens: FxHashMap<Key, [Option<Tween>; SLOTS]>,
557    /// The transform slot's tweens (ADR 0043), by node, for the nodes that
558    /// turn: kept apart from `tweens` so the nodes that do not — almost
559    /// all of them — carry nothing for it.
560    turns: FxHashMap<Key, Option<Tween>>,
561    /// When a finite keyframe cycle began (backlog F133), by node, with
562    /// the last frame that sampled it: a cycle of `iterations` plays from
563    /// the first frame its node is declared with them, where an infinite
564    /// one reads the clock as it is so that siblings stay in phase. A node
565    /// a frame went without starts again, as an entrance does.
566    starts: FxHashMap<Key, (f64, u64)>,
567    /// Driver time in seconds; None until the driver first sets it.
568    now: Option<f64>,
569    /// The core's frame counter as of `begin_frame`.
570    frame_no: u64,
571    /// The clock this frame drives at, as of `begin_frame`: what
572    /// [`Self::owing`] reads a leg's progress at once the frame is over,
573    /// since the driver sets the next frame's time before that frame
574    /// begins.
575    drove_at: Option<f64>,
576    /// Whether any tween driven this frame is still mid-flight — a
577    /// finite leg, or a spring not yet at rest.
578    owes_transition: bool,
579    /// Whether a keyframed slot was sampled this frame. A cycle has no
580    /// end, so this is set on every frame the node is drawn; kept apart
581    /// from the flag above so a test can wait for the transitions to run
582    /// out under a cycle that never will.
583    owes_cycle: bool,
584}
585
586impl AnimStore {
587    /// Sets the frame clock (monotonic seconds, any origin). Drivers call it
588    /// before each frame; never calling it means transitions snap.
589    pub fn set_time(&mut self, now_secs: f64) {
590        self.now = Some(now_secs);
591    }
592
593    pub fn time(&self) -> Option<f64> {
594        self.now
595    }
596
597    /// True when the last frame left a transition mid-flight or a cycle
598    /// running, i.e. the driver should schedule another frame without
599    /// waiting for input.
600    pub fn animating(&self) -> bool {
601        self.owes_transition || self.owes_cycle
602    }
603
604    /// The two halves of [`animating`](Self::animating): a finite
605    /// transition still mid-flight, and a keyframe cycle running.
606    pub fn owes(&self) -> (bool, bool) {
607        (self.owes_transition, self.owes_cycle)
608    }
609
610    /// Starts a frame: `frame_no` is the core's counter, stamped on what
611    /// this frame drives.
612    pub(crate) fn begin_frame(&mut self, frame_no: u64) {
613        self.frame_no = frame_no;
614        self.drove_at = self.now;
615        self.owes_transition = false;
616        self.owes_cycle = false;
617        // Tweens nothing has driven for a while go (`retain::sweep_cutoff`).
618        if !self.tweens.is_empty()
619            && let Some(cutoff) = crate::retain::sweep_cutoff(self.frame_no)
620        {
621            self.tweens
622                .retain(|_, slots| slots.iter().flatten().any(|t| t.last_used >= cutoff));
623        }
624        if !self.starts.is_empty()
625            && let Some(cutoff) = crate::retain::sweep_cutoff(self.frame_no)
626        {
627            self.starts.retain(|_, (_, used)| *used >= cutoff);
628        }
629        if !self.turns.is_empty()
630            && let Some(cutoff) = crate::retain::sweep_cutoff(self.frame_no)
631        {
632            self.turns
633                .retain(|_, t| t.is_some_and(|t| t.last_used >= cutoff));
634        }
635    }
636
637    /// Every slot the last frame drove and left mid-flight, by node: what
638    /// [`Self::owes`]'s `transition` half is made of, read back off the
639    /// tweens rather than recorded while they were driven, so a frame
640    /// nobody traces pays nothing for it. Asked between
641    /// frames, or before this store's `begin_frame`: the same test
642    /// `drive` made — a leg short of its end at the frame's clock, a
643    /// spring not yet snapped to rest.
644    pub(crate) fn owing(&self, mut each: impl FnMut(Key, Slot)) {
645        let Some(now) = self.drove_at.or(self.now) else {
646            return;
647        };
648        let moving = |tw: &Tween| {
649            tw.last_used == self.frame_no
650                && if tw.easing.is_spring() {
651                    tw.value != tw.to || tw.velocity != [0.0; 4]
652                } else {
653                    tw.progress_at(now) < 1.0
654                }
655        };
656        for (&key, slots) in &self.tweens {
657            for (i, tw) in slots.iter().enumerate() {
658                if tw.as_ref().is_some_and(moving) {
659                    each(key, Slot::ALL[i]);
660                }
661            }
662        }
663        for (&key, tw) in &self.turns {
664            if tw.as_ref().is_some_and(moving) {
665                each(key, Slot::Transform);
666            }
667        }
668    }
669
670    /// Eases `key`'s `slot` toward `target`, returning the value to use this
671    /// frame. A slot not driven last frame (node just appeared, or rendered
672    /// without a transition in between) snaps: nothing animates in from
673    /// nowhere, and a drag that disabled the transition doesn't replay —
674    /// unless `enter_from` says where such a slot starts, in which case its
675    /// first leg runs from there (an `enter` prop). `follow` off means the
676    /// slot only eases that entrance: once it has settled, a new target
677    /// snaps as it would without a transition — a node whose position
678    /// enters but doesn't `slide`.
679    pub(crate) fn drive(
680        &mut self,
681        key: Key,
682        slot: Slot,
683        enter_from: Option<[f32; 4]>,
684        target: [f32; 4],
685        transition: Transition,
686        follow: bool,
687    ) -> [f32; 4] {
688        self.node(key)
689            .drive(slot, enter_from, target, transition, follow)
690    }
691
692    /// The tween slots of one node, borrowed once.
693    ///
694    /// A node that declares a transition drives up to ten slots in a row
695    /// (`Core::ease_transitioning`), and each of those used to ask the map
696    /// for the same key — nine hashes and nine probes per node per frame,
697    /// which on a frame where every node transitions was the largest single
698    /// entry in the profile. The lookup happens here instead and the borrow
699    /// serves every slot. `sample` rides along because a keyframed slot is
700    /// reached from the same walk and sets a flag beside `active`'s, not
701    /// because it needs the slots: a sampled track is not retained.
702    pub(crate) fn node(&mut self, key: Key) -> NodeAnim<'_> {
703        NodeAnim {
704            slots: self.tweens.entry(key).or_default(),
705            key,
706            starts: &mut self.starts,
707            now: self.now,
708            frame_no: self.frame_no,
709            active: &mut self.owes_transition,
710            cycling: &mut self.owes_cycle,
711        }
712    }
713
714    /// Whether `key`'s keyframe cycle is still running at this frame's
715    /// clock: always for an infinite one, and for one of `iterations`
716    /// until its last iteration ends (backlog F133) — what a trace of the
717    /// frames owed names as a cycle.
718    pub(crate) fn cycle_running(
719        &self,
720        key: Key,
721        transition: Transition,
722        iterations: Option<f32>,
723    ) -> bool {
724        let Some(n) = iterations else {
725            return true;
726        };
727        let (Some(now), Some(&(start, _))) = (self.drove_at.or(self.now), self.starts.get(&key))
728        else {
729            return false;
730        };
731        let dur = transition.duration_ms as f64 / 1000.0;
732        dur > 0.0 && (now - start - transition.delay_ms as f64 / 1000.0) / dur < n as f64
733    }
734
735    /// [`NodeAnim::drive`] for the transform slot, whose tweens are kept
736    /// apart (`turns`): the same leg, the same rules.
737    /// Whether `key` has a turn tween the last frame drove: a node that
738    /// stops declaring its turn eases back to upright on it rather than
739    /// snapping. One branch while nothing anywhere turns.
740    #[inline]
741    pub(crate) fn turn_live(&self, key: Key) -> bool {
742        !self.turns.is_empty()
743            && self
744                .turns
745                .get(&key)
746                .and_then(Option::as_ref)
747                .is_some_and(|t| t.last_used + 1 >= self.frame_no)
748    }
749
750    /// Whether `key` was drawn transitioning last frame with no live turn:
751    /// a turn it declares now eases in from upright, as a width it starts
752    /// declaring eases from the one it had, rather than appearing at once.
753    /// Read before this frame drives the node's other slots.
754    pub(crate) fn turn_starts_upright(&self, key: Key) -> bool {
755        let prev = self.frame_no.wrapping_sub(1);
756        !self.turn_live(key)
757            && self
758                .tweens
759                .get(&key)
760                .is_some_and(|s| s.iter().flatten().any(|t| t.last_used == prev))
761    }
762
763    /// Drops `key`'s turn tween once it has come to rest upright on a node
764    /// that declares no turn, so the node stops paying the lookup.
765    pub(crate) fn forget_settled_turn(&mut self, key: Key, identity: [f32; 4]) {
766        if let Some(Some(t)) = self.turns.get(&key)
767            && t.value == identity
768            && t.to == identity
769            && t.velocity == [0.0; 4]
770        {
771            self.turns.remove(&key);
772        }
773    }
774
775    pub(crate) fn drive_turn(
776        &mut self,
777        key: Key,
778        enter_from: Option<[f32; 4]>,
779        target: [f32; 4],
780        transition: Transition,
781    ) -> [f32; 4] {
782        let entry = self.turns.entry(key).or_default();
783        drive_entry(
784            entry,
785            self.now,
786            self.frame_no,
787            &mut self.owes_transition,
788            enter_from,
789            target,
790            transition,
791            true,
792        )
793    }
794
795    /// [`NodeAnim::sample`] for a track the node's slot array does not
796    /// hold — the transform's, a position's (backlog F132).
797    pub(crate) fn sample_cycle(
798        &mut self,
799        key: Key,
800        track: &Track,
801        transition: Transition,
802        iterations: Option<f32>,
803    ) -> Option<[f32; 4]> {
804        let now = self.now?;
805        let finite =
806            iterations.map(|n| (n, cycle_start(&mut self.starts, key, now, self.frame_no)));
807        let (v, running) = sample_track(track, transition, Some(now), finite)?;
808        if running {
809            self.owes_cycle = true;
810        }
811        Some(v)
812    }
813}
814
815/// Where `now` lands in `track`'s cycle, shaped by `transition`'s easing.
816/// The walk behind [`NodeAnim::sample`]. None without a clock or a
817/// duration.
818///
819/// `finite` is a cycle of so many iterations and the clock reading it began
820/// at (backlog F133): it holds its first stop through its `delay`, runs its
821/// iterations, then rests where the last one ended. The bool is whether the
822/// cycle is still running — a finite one that is over owes no frame.
823fn sample_track(
824    track: &Track,
825    transition: Transition,
826    now: Option<f64>,
827    finite: Option<(f32, f64)>,
828) -> Option<([f32; 4], bool)> {
829    let dur = transition.duration_ms as f64 / 1000.0;
830    let (Some(now), true, Some(&(_, first))) = (now, dur > 0.0, track.first()) else {
831        return None;
832    };
833    let delay = transition.delay_ms as f64 / 1000.0;
834    let (p, running) = match finite {
835        None => (transition.repeat.progress((now - delay) / dur), true),
836        Some((n, start)) => {
837            let u = (now - start - delay) / dur;
838            if u <= 0.0 {
839                (transition.repeat.start(), true)
840            } else if u >= n as f64 {
841                (transition.repeat.end(n as f64), false)
842            } else {
843                (transition.repeat.progress(u), true)
844            }
845        }
846    };
847    let v = segment_at(track, transition, p, first);
848    Some((v, running))
849}
850
851/// The value at cycle position `p` (0..=1) along `track`, the easing
852/// applied per segment, as CSS applies its timing function per keyframe
853/// interval.
854fn segment_at(track: &Track, transition: Transition, p: f32, first: [f32; 4]) -> [f32; 4] {
855    let mut from = (0.0, first);
856    for &(at, value) in track {
857        if p < at {
858            let (a, va) = from;
859            let t = if at > a { (p - a) / (at - a) } else { 1.0 };
860            let e = transition.curve().apply(t);
861            let mut out = [0.0; 4];
862            for i in 0..4 {
863                out[i] = va[i] + (value[i] - va[i]) * e;
864            }
865            return out;
866        }
867        from = (at, value);
868    }
869    from.1
870}
871
872/// When `key`'s finite cycle began: now, the first frame it is sampled
873/// and the first after a frame that went without it; else what it was.
874fn cycle_start(starts: &mut FxHashMap<Key, (f64, u64)>, key: Key, now: f64, frame_no: u64) -> f64 {
875    let e = starts.entry(key).or_insert((now, frame_no));
876    if e.1 + 1 < frame_no {
877        e.0 = now;
878    }
879    e.1 = frame_no;
880    e.0
881}
882
883/// One node's animation state for this frame: its retained tween slots,
884/// the clock, and the store's "another frame is owed" flag. Made by
885/// [`AnimStore::node`].
886pub(crate) struct NodeAnim<'a> {
887    slots: &'a mut [Option<Tween>; SLOTS],
888    /// The node, for a finite cycle's start (`starts`).
889    key: Key,
890    starts: &'a mut FxHashMap<Key, (f64, u64)>,
891    /// Driver time in seconds; None until the driver first sets it.
892    now: Option<f64>,
893    frame_no: u64,
894    /// The store's "a transition is mid-flight" flag.
895    active: &'a mut bool,
896    /// The store's "a cycle is running" flag (F64).
897    cycling: &'a mut bool,
898}
899
900impl NodeAnim<'_> {
901    /// Samples a keyframed slot: where the clock lands in the cycle picks
902    /// a segment of `track`, and the transition's easing shapes that
903    /// segment (CSS applies the timing function per keyframe interval, so
904    /// an alternate cycle traverses the same curve backwards on its way
905    /// home). Sampled, not retained: this needs no key and cannot drift,
906    /// so two nodes with the same duration stay locked together. Spring
907    /// easings sample as ease-out — there is no leg to carry momentum
908    /// across.
909    ///
910    /// None without a clock or a duration: the caller keeps the declared
911    /// value, the same snap a plain transition gets.
912    ///
913    /// It is reached from a node borrow only because the caller holds one
914    /// (`Core::ease_transitioning` walks a node's slots and its keyframed
915    /// ones together) and because the flag it sets lives behind that
916    /// borrow — the *cycle* flag, not the transition one: a cycle never
917    /// ends, and a wait for the transitions to run out must not wait on
918    /// it. The walk itself is [`sample_track`] and takes no
919    /// key.
920    pub(crate) fn sample(
921        &mut self,
922        track: &Track,
923        transition: Transition,
924        iterations: Option<f32>,
925    ) -> Option<[f32; 4]> {
926        let now = self.now?;
927        let finite =
928            iterations.map(|n| (n, cycle_start(self.starts, self.key, now, self.frame_no)));
929        let (v, running) = sample_track(track, transition, Some(now), finite)?;
930        if running {
931            *self.cycling = true;
932        }
933        Some(v)
934    }
935
936    #[inline]
937    pub(crate) fn drive(
938        &mut self,
939        slot: Slot,
940        enter_from: Option<[f32; 4]>,
941        target: [f32; 4],
942        transition: Transition,
943        follow: bool,
944    ) -> [f32; 4] {
945        // The transform slot is not in the array (see `SLOTS`); its
946        // tweens are driven through `AnimStore::drive_turn`.
947        debug_assert!(slot != Slot::Transform);
948        drive_entry(
949            &mut self.slots[slot as usize],
950            self.now,
951            self.frame_no,
952            self.active,
953            enter_from,
954            target,
955            transition,
956            follow,
957        )
958    }
959}
960
961/// One slot's leg driven toward `target` this frame — what
962/// [`NodeAnim::drive`] and [`AnimStore::drive_turn`] both are, over the
963/// entry each keeps.
964///
965/// Out of line, and the one body both call: `NodeAnim::drive` was an
966/// out-of-line function before ADR 0043, with `Tween::eased_at` and
967/// `spring_step` inlined into it as its only callers. Inlined into the two
968/// instead, each helper had two callers, the compiler stopped inlining
969/// them, and a frame of 10,000 transitioning boxes paid 4% for the calls.
970#[inline(never)]
971#[allow(clippy::too_many_arguments)]
972fn drive_entry(
973    entry: &mut Option<Tween>,
974    now: Option<f64>,
975    frame_no: u64,
976    active: &mut bool,
977    enter_from: Option<[f32; 4]>,
978    target: [f32; 4],
979    transition: Transition,
980    follow: bool,
981) -> [f32; 4] {
982    {
983        let stale = entry.is_none_or(|t| t.last_used + 1 < frame_no);
984        let Some(now) = now else {
985            *entry = Some(Tween::settled(target, 0.0, 0.0, transition, frame_no));
986            return target;
987        };
988        if stale {
989            match enter_from {
990                // The entrance: a leg from the declared start, begun now.
991                Some(from) if from != target => {
992                    *entry = Some(Tween::leg(from, target, now, transition, frame_no));
993                }
994                // Settled from the start: a leg that began infinitely long
995                // ago is complete, so nothing is owed until a retarget.
996                _ => {
997                    *entry = Some(Tween::settled(
998                        target,
999                        f64::NEG_INFINITY,
1000                        now,
1001                        transition,
1002                        frame_no,
1003                    ));
1004                    return target;
1005                }
1006            }
1007        }
1008        let tw = entry.as_mut().expect("checked above");
1009        tw.last_used = frame_no;
1010        if !follow && tw.to != target && tw.value == tw.to && tw.velocity == [0.0; 4] {
1011            // Off the leash: settled, and the view moved it — snap.
1012            tw.from = target;
1013            tw.to = target;
1014            tw.value = target;
1015            tw.start = f64::NEG_INFINITY;
1016            tw.last_time = now;
1017            return target;
1018        }
1019        if let Some(zeta) = transition.damping() {
1020            // Springs retarget freely: the velocity carries over.
1021            tw.to = target;
1022            tw.duration_ms = transition.duration_ms;
1023            tw.easing = transition.curve();
1024            if tw.spring_step(now, zeta) {
1025                *active = true;
1026            }
1027            return tw.value;
1028        }
1029        if tw.to != target {
1030            // Where the running leg stands *at `now`*, not where it stood
1031            // when it was last sampled. The difference is the whole frame:
1032            // a new leg starts at `p == 0`, so retargeting from the stale
1033            // value spends none of the elapsed time — and a target the view
1034            // moves every frame (a canvas of `slide` floats under a drag)
1035            // then retargets every frame and never advances at all. Backlog
1036            // F15: the pan was live in the model and frozen on screen.
1037            tw.from = tw.eased_at(now);
1038            tw.to = target;
1039            tw.start = now;
1040            tw.duration_ms = transition.duration_ms;
1041            tw.easing = transition.curve();
1042        }
1043        let p = tw.progress_at(now);
1044        if p < 1.0 {
1045            *active = true;
1046        }
1047        tw.value = tw.at(p);
1048        tw.value
1049    }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    /// The core's frame counter, stood in for: each test's frames count
1057    /// from one.
1058    struct Frames(u64);
1059    impl Frames {
1060        fn next(&mut self) -> u64 {
1061            self.0 += 1;
1062            self.0
1063        }
1064    }
1065
1066    /// `Tween` is retained per slot, nine slots per transitioning node, and
1067    /// walked in full every frame the node is driven: a 10,000-node frame
1068    /// with a transition on every node carries 8.6 MB of it. So a field
1069    /// added here is paid nine times per node forever, and this is the
1070    /// number a review can fail — the same guard `NodeSpec` carries, for
1071    /// the same reason (C15).
1072    ///
1073    /// Raise it only for something a *leg* reads. What belongs to the node
1074    /// rather than the leg goes on the node, and what belongs to the
1075    /// keyframe cycle goes nowhere near here: `Transition::repeat` and
1076    /// `delay_ms` used to ride along and were read by nobody.
1077    #[test]
1078    fn a_tween_stays_small() {
1079        const BOUND: usize = 96;
1080        let size = std::mem::size_of::<Option<Tween>>();
1081        assert!(
1082            size <= BOUND,
1083            "Option<Tween> is {size} bytes, over the {BOUND}-byte bound. \
1084             It is held per slot per node across frames; put what the node \
1085             owns on the node and what the cycle owns in the Transition."
1086        );
1087        // The discriminant rides in `Easing`'s niche rather than widening
1088        // the struct; a field ordering that loses that is worth noticing.
1089        assert_eq!(size, std::mem::size_of::<Tween>());
1090    }
1091
1092    fn one(v: f32) -> [f32; 4] {
1093        [v, 0.0, 0.0, 0.0]
1094    }
1095
1096    #[test]
1097    fn easings_hit_their_endpoints() {
1098        for e in [
1099            Easing::Linear,
1100            Easing::EaseOut,
1101            Easing::EaseIn,
1102            Easing::EaseInOut,
1103            Easing::Spring,
1104            Easing::Bouncy,
1105            Easing::Smooth,
1106            Easing::Snappy,
1107        ] {
1108            assert_eq!(e.apply(0.0), 0.0);
1109            assert_eq!(e.apply(1.0), 1.0);
1110            assert!(e.apply(0.5) > 0.0 && e.apply(0.5) < 1.0);
1111        }
1112    }
1113
1114    #[test]
1115    fn first_sight_snaps_then_retargets_ease() {
1116        let mut a = AnimStore::default();
1117        let mut frame = Frames(0);
1118        let k = Key::ROOT.str("x");
1119        let t = Transition::ms(100.0).easing(Easing::Linear);
1120        a.set_time(0.0);
1121        a.begin_frame(frame.next());
1122        assert_eq!(a.drive(k, Slot::Width, None, one(10.0), t, true)[0], 10.0);
1123        assert!(!a.animating());
1124
1125        a.set_time(0.0);
1126        a.begin_frame(frame.next());
1127        assert_eq!(a.drive(k, Slot::Width, None, one(20.0), t, true)[0], 10.0);
1128        assert!(a.animating(), "mid-flight after retarget");
1129
1130        a.set_time(0.05);
1131        a.begin_frame(frame.next());
1132        assert!((a.drive(k, Slot::Width, None, one(20.0), t, true)[0] - 15.0).abs() < 1e-4);
1133
1134        a.set_time(0.2);
1135        a.begin_frame(frame.next());
1136        assert_eq!(a.drive(k, Slot::Width, None, one(20.0), t, true)[0], 20.0);
1137        assert!(!a.animating(), "settled");
1138    }
1139
1140    #[test]
1141    fn unchanged_targets_owe_no_frames() {
1142        let mut a = AnimStore::default();
1143        let mut frame = Frames(0);
1144        let k = Key::ROOT.str("x");
1145        let t = Transition::ms(100.0);
1146        for i in 0..3 {
1147            a.set_time(i as f64 * 0.001);
1148            a.begin_frame(frame.next());
1149            a.drive(k, Slot::Width, None, one(5.0), t, true);
1150            assert!(!a.animating(), "frame {i}: same value, nothing to animate");
1151        }
1152    }
1153
1154    #[test]
1155    fn retarget_mid_flight_starts_from_current_value() {
1156        let mut a = AnimStore::default();
1157        let mut frame = Frames(0);
1158        let k = Key::ROOT.str("x");
1159        let t = Transition::ms(100.0).easing(Easing::Linear);
1160        a.set_time(0.0);
1161        a.begin_frame(frame.next());
1162        a.drive(k, Slot::Width, None, one(0.0), t, true);
1163        a.set_time(0.0);
1164        a.begin_frame(frame.next());
1165        a.drive(k, Slot::Width, None, one(100.0), t, true);
1166        a.set_time(0.05);
1167        a.begin_frame(frame.next());
1168        assert!((a.drive(k, Slot::Width, None, one(100.0), t, true)[0] - 50.0).abs() < 1e-4);
1169        // Reverse: eases back from 50, not from 100.
1170        a.set_time(0.05);
1171        a.begin_frame(frame.next());
1172        assert!((a.drive(k, Slot::Width, None, one(0.0), t, true)[0] - 50.0).abs() < 1e-4);
1173        a.set_time(0.10);
1174        a.begin_frame(frame.next());
1175        assert!((a.drive(k, Slot::Width, None, one(0.0), t, true)[0] - 25.0).abs() < 1e-4);
1176    }
1177
1178    #[test]
1179    fn springs_overshoot_settle_and_keep_momentum() {
1180        let mut a = AnimStore::default();
1181        let mut frame = Frames(0);
1182        let k = Key::ROOT.str("x");
1183        let t = Transition::ms(200.0).easing(Easing::Bouncy);
1184        a.set_time(0.0);
1185        a.begin_frame(frame.next());
1186        a.drive(k, Slot::Width, None, one(0.0), t, true);
1187        // Retarget to 100 and step at 60Hz.
1188        let mut max = 0.0f32;
1189        let mut settled_at = None;
1190        for i in 1..=180 {
1191            a.set_time(i as f64 / 60.0);
1192            a.begin_frame(frame.next());
1193            let v = a.drive(k, Slot::Width, None, one(100.0), t, true)[0];
1194            max = max.max(v);
1195            if !a.animating() && settled_at.is_none() {
1196                settled_at = Some(i);
1197            }
1198        }
1199        assert!(max > 101.0, "bouncy overshoots: peak {max}");
1200        let settled = settled_at.expect("settles within 3s");
1201        assert!(settled > 6, "not instant: {settled}");
1202        a.set_time(4.0);
1203        a.begin_frame(frame.next());
1204        assert_eq!(a.drive(k, Slot::Width, None, one(100.0), t, true)[0], 100.0);
1205
1206        // Momentum: retargeting mid-flight continues from the current
1207        // velocity rather than restarting, so the value keeps moving up
1208        // for a moment even though the new target is behind it.
1209        a.set_time(4.0);
1210        a.begin_frame(frame.next());
1211        a.drive(k, Slot::Width, None, one(200.0), t, true);
1212        let mut v_prev = 100.0;
1213        for i in 1..=2 {
1214            a.set_time(4.0 + i as f64 / 60.0);
1215            a.begin_frame(frame.next());
1216            v_prev = a.drive(k, Slot::Width, None, one(200.0), t, true)[0];
1217        }
1218        assert!(v_prev > 100.0);
1219        a.set_time(4.0 + 3.0 / 60.0);
1220        a.begin_frame(frame.next());
1221        let after = a.drive(k, Slot::Width, None, one(100.0), t, true)[0];
1222        assert!(
1223            after > v_prev,
1224            "momentum carries past the retarget: {v_prev} -> {after}"
1225        );
1226    }
1227
1228    /// The highest a slot eased from 0 to 100 under `t` reads, stepped at
1229    /// 60Hz for three seconds; and whether it came to rest by then.
1230    fn peak(t: Transition) -> (f32, bool) {
1231        let mut a = AnimStore::default();
1232        let mut frame = Frames(0);
1233        let k = Key::ROOT.str("x");
1234        a.set_time(0.0);
1235        a.begin_frame(frame.next());
1236        a.drive(k, Slot::Width, None, one(0.0), t, true);
1237        let mut max = 0.0f32;
1238        for i in 1..=180 {
1239            a.set_time(i as f64 / 60.0);
1240            a.begin_frame(frame.next());
1241            max = max.max(a.drive(k, Slot::Width, None, one(100.0), t, true)[0]);
1242        }
1243        (max, !a.animating())
1244    }
1245
1246    /// Each spring easing is a bounce, and the bounce is what a person
1247    /// reads off the screen: none glides in under the target, and more
1248    /// overshoots further. `spring` and `bouncy` keep the damping ratios
1249    /// they had before they were named bounces (0.75 and 0.5).
1250    #[test]
1251    fn a_spring_s_bounce_is_how_far_it_overshoots() {
1252        let t = Transition::ms(200.0);
1253        assert_eq!(t.easing(Easing::Spring).damping(), Some(0.75));
1254        assert_eq!(t.easing(Easing::Bouncy).damping(), Some(0.5));
1255        assert_eq!(t.damping(), None, "a timed curve is no spring");
1256
1257        let (smooth, settled) = peak(t.easing(Easing::Smooth));
1258        assert!(settled, "smooth comes to rest");
1259        assert!(smooth <= 100.0 + 1e-3, "smooth never overshoots: {smooth}");
1260        let mut last = smooth;
1261        for e in [Easing::Snappy, Easing::Spring, Easing::Bouncy] {
1262            let (p, settled) = peak(t.easing(e));
1263            assert!(settled, "{e:?} comes to rest");
1264            assert!(
1265                p > last,
1266                "{e:?} overshoots past the one before: {p} <= {last}"
1267            );
1268            last = p;
1269        }
1270        // A bounce of its own replaces the easing's, either way.
1271        assert_eq!(
1272            peak(t.easing(Easing::Bouncy).bounce(0.0)).0,
1273            smooth,
1274            "bouncy with no bounce is smooth"
1275        );
1276        assert_eq!(
1277            peak(t.easing(Easing::Smooth).bounce(0.5)).0,
1278            last,
1279            "smooth with bouncy's bounce is bouncy"
1280        );
1281    }
1282
1283    /// A bounce is only a spring's to have, so one given to a timed curve
1284    /// makes the transition a spring rather than being dropped: `transition`
1285    /// and `bounce` alone are a spring of that length and bounce.
1286    #[test]
1287    fn a_bounce_on_a_timed_curve_makes_it_a_spring() {
1288        let t = Transition::ms(200.0).easing(Easing::Linear).bounce(0.5);
1289        assert_eq!(t.curve(), Easing::Spring);
1290        assert_eq!(t.damping(), Some(0.5));
1291        assert_eq!(
1292            peak(t).0,
1293            peak(Transition::ms(200.0).easing(Easing::Bouncy)).0
1294        );
1295        assert_eq!(Transition::ms(200.0).curve(), Easing::EaseOut);
1296    }
1297
1298    #[test]
1299    fn a_bounce_holds_its_range() {
1300        assert!((Bounce::new(0.3).get() - 0.3).abs() < 1e-4);
1301        assert_eq!(Bounce::new(0.0).get(), 0.0);
1302        assert_eq!(Bounce::new(-1.0).get(), 0.0);
1303        assert_eq!(Bounce::new(f32::NAN).get(), 0.0);
1304        assert_eq!(Bounce::new(1.0).get(), MAX_BOUNCE, "1 would never settle");
1305        let (_, settled) = peak(Transition::ms(100.0).easing(Easing::Spring).bounce(1.0));
1306        assert!(settled, "the most bounce there is still comes to rest");
1307    }
1308
1309    /// `Transition` rides inline in every `NodeSpec`, which sits at its
1310    /// bound (`node_spec_stays_small`): the bounce had to fit in the two
1311    /// bytes of padding it had spare.
1312    #[test]
1313    fn a_transition_carries_its_bounce_in_its_padding() {
1314        assert_eq!(std::mem::size_of::<Option<Bounce>>(), 2);
1315        assert_eq!(std::mem::size_of::<Option<Transition>>(), 12);
1316    }
1317
1318    #[test]
1319    fn an_entrance_starts_its_first_leg_from_the_declared_value() {
1320        let mut a = AnimStore::default();
1321        let mut frame = Frames(0);
1322        let k = Key::ROOT.str("x");
1323        let t = Transition::ms(100.0).easing(Easing::Linear);
1324        a.set_time(0.0);
1325        a.begin_frame(frame.next());
1326        assert_eq!(
1327            a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0],
1328            -100.0,
1329            "first sight starts at the entrance"
1330        );
1331        assert!(a.animating(), "and owes a frame");
1332        a.set_time(0.05);
1333        a.begin_frame(frame.next());
1334        assert!(
1335            (a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0] + 50.0).abs() < 1e-3
1336        );
1337        a.set_time(0.2);
1338        a.begin_frame(frame.next());
1339        assert_eq!(
1340            a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0],
1341            0.0
1342        );
1343        assert!(!a.animating());
1344        // Once seen, the entrance is spent: a retarget eases from where it is.
1345        a.set_time(0.2);
1346        a.begin_frame(frame.next());
1347        assert_eq!(
1348            a.drive(k, Slot::Pos, Some(one(-100.0)), one(40.0), t, true)[0],
1349            0.0
1350        );
1351        a.set_time(0.25);
1352        a.begin_frame(frame.next());
1353        assert!(
1354            (a.drive(k, Slot::Pos, Some(one(-100.0)), one(40.0), t, true)[0] - 20.0).abs() < 1e-3
1355        );
1356    }
1357
1358    #[test]
1359    fn an_entrance_equal_to_the_target_is_no_entrance() {
1360        let mut a = AnimStore::default();
1361        let mut frame = Frames(0);
1362        let k = Key::ROOT.str("x");
1363        let t = Transition::ms(100.0);
1364        a.set_time(0.0);
1365        a.begin_frame(frame.next());
1366        assert_eq!(
1367            a.drive(k, Slot::Bg, Some(one(5.0)), one(5.0), t, true)[0],
1368            5.0
1369        );
1370        assert!(!a.animating());
1371    }
1372
1373    #[test]
1374    fn a_spring_entrance_carries_no_velocity_in() {
1375        let mut a = AnimStore::default();
1376        let mut frame = Frames(0);
1377        let k = Key::ROOT.str("x");
1378        let t = Transition::ms(100.0).easing(Easing::Spring);
1379        a.set_time(0.0);
1380        a.begin_frame(frame.next());
1381        assert_eq!(
1382            a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0],
1383            -100.0
1384        );
1385        a.set_time(0.016);
1386        a.begin_frame(frame.next());
1387        let first = a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0];
1388        assert!(
1389            first > -100.0 && first < 0.0,
1390            "leaves the entrance toward the target: {first}"
1391        );
1392        assert!(a.animating());
1393        let mut v = first;
1394        for i in 2..=90 {
1395            a.set_time(i as f64 * 0.016);
1396            a.begin_frame(frame.next());
1397            v = a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0];
1398        }
1399        assert!(v.abs() < 1.0, "settled on the target: {v}");
1400        assert!(!a.animating());
1401    }
1402
1403    #[test]
1404    fn off_the_leash_a_settled_slot_snaps_to_a_new_target() {
1405        let mut a = AnimStore::default();
1406        let mut frame = Frames(0);
1407        let k = Key::ROOT.str("x");
1408        let t = Transition::ms(100.0).easing(Easing::Linear);
1409        a.set_time(0.0);
1410        a.begin_frame(frame.next());
1411        a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, false);
1412        // Retargeted mid-entrance: still eases, a fresh leg from where it is.
1413        a.set_time(0.05);
1414        a.begin_frame(frame.next());
1415        let at = a.drive(k, Slot::Pos, Some(one(-100.0)), one(20.0), t, false)[0];
1416        a.set_time(0.1);
1417        a.begin_frame(frame.next());
1418        let mid = a.drive(k, Slot::Pos, Some(one(-100.0)), one(20.0), t, false)[0];
1419        assert!(
1420            at < mid && mid < 20.0,
1421            "mid-flight retarget keeps easing: {at} -> {mid}"
1422        );
1423        a.set_time(0.3);
1424        a.begin_frame(frame.next());
1425        assert_eq!(
1426            a.drive(k, Slot::Pos, Some(one(-100.0)), one(20.0), t, false)[0],
1427            20.0
1428        );
1429        assert!(!a.animating());
1430        // Settled and moved by layout: a node that doesn't slide snaps.
1431        a.set_time(0.3);
1432        a.begin_frame(frame.next());
1433        assert_eq!(
1434            a.drive(k, Slot::Pos, Some(one(-100.0)), one(300.0), t, false)[0],
1435            300.0
1436        );
1437        assert!(!a.animating());
1438    }
1439
1440    /// A canvas of `slide` floats panned by
1441    /// a drag retargets every slot on every frame. Each retarget starts a
1442    /// fresh leg at `p == 0`, so a tween that reads its stale value spends
1443    /// none of the frame's time and never moves — the pan is live in the
1444    /// model and frozen on screen. It has to follow, a fixed distance back.
1445    #[test]
1446    fn a_target_that_moves_every_frame_still_advances() {
1447        let mut a = AnimStore::default();
1448        let mut frame = Frames(0);
1449        let k = Key::ROOT.str("card");
1450        let t = Transition::ms(160.0);
1451        a.set_time(0.0);
1452        a.begin_frame(frame.next());
1453        a.drive(k, Slot::Pos, None, one(0.0), t, true);
1454        // 16 ms frames, 9.6 px of pan each: a second of a drag in flight.
1455        let mut behind = Vec::new();
1456        let mut drawn = 0.0;
1457        for f in 1..=60 {
1458            let target = f as f32 * 9.6;
1459            a.set_time(f as f64 * 0.016);
1460            a.begin_frame(frame.next());
1461            drawn = a.drive(k, Slot::Pos, None, one(target), t, true)[0];
1462            behind.push(target - drawn);
1463        }
1464        let target = 60.0 * 9.6;
1465        assert!(
1466            drawn > target * 0.8,
1467            "the pan reaches the screen: drawn {drawn} of {target}"
1468        );
1469        // And it trails by a fixed distance rather than falling further
1470        // behind every frame — one transition's worth of travel, no more.
1471        let (early, late) = (behind[29], behind[59]);
1472        assert!(
1473            (early - late).abs() < 1.0,
1474            "the gap stops growing: {early} then {late}"
1475        );
1476    }
1477
1478    #[test]
1479    fn a_slot_skipped_for_a_frame_snaps() {
1480        let mut a = AnimStore::default();
1481        let mut frame = Frames(0);
1482        let k = Key::ROOT.str("x");
1483        let t = Transition::ms(100.0);
1484        a.set_time(0.0);
1485        a.begin_frame(frame.next());
1486        a.drive(k, Slot::Width, None, one(0.0), t, true);
1487        // A frame without this node (or without its transition).
1488        a.set_time(0.01);
1489        a.begin_frame(frame.next());
1490        a.set_time(0.02);
1491        a.begin_frame(frame.next());
1492        assert_eq!(a.drive(k, Slot::Width, None, one(100.0), t, true)[0], 100.0);
1493        assert!(!a.animating());
1494    }
1495
1496    /// Two stops, linear: the value reads as a straight function of the
1497    /// clock in each of CSS's four directions.
1498    #[test]
1499    fn keyframes_cycle_in_every_direction() {
1500        let track = [(0.0, one(0.0)), (1.0, one(100.0))];
1501        let mut frame = Frames(0);
1502        let mut at = |a: &mut AnimStore, repeat: Repeat, now: f64| {
1503            let t = Transition::ms(1000.0).easing(Easing::Linear).repeat(repeat);
1504            a.set_time(now);
1505            a.begin_frame(frame.next());
1506            let v = a.node(Key::ROOT).sample(&track, t, None).unwrap()[0];
1507            assert!(a.animating(), "a keyframed slot always owes a frame");
1508            v
1509        };
1510        let mut a = AnimStore::default();
1511        for (repeat, expect) in [
1512            (Repeat::Normal, [0.0, 25.0, 50.0, 75.0, 0.0, 25.0]),
1513            (Repeat::Reverse, [100.0, 75.0, 50.0, 25.0, 100.0, 75.0]),
1514            (Repeat::Alternate, [0.0, 25.0, 50.0, 75.0, 100.0, 75.0]),
1515            (
1516                Repeat::AlternateReverse,
1517                [100.0, 75.0, 50.0, 25.0, 0.0, 25.0],
1518            ),
1519        ] {
1520            for (i, e) in expect.iter().enumerate() {
1521                let v = at(&mut a, repeat, i as f64 * 0.25);
1522                assert!((v - e).abs() < 1e-3, "{repeat:?} at {i}/4: {v} != {e}");
1523            }
1524        }
1525    }
1526
1527    #[test]
1528    fn delay_shifts_the_cycle_and_easing_shapes_each_segment() {
1529        let mut a = AnimStore::default();
1530        let mut frame = Frames(0);
1531        let track = [(0.0, one(0.0)), (0.5, one(10.0)), (1.0, one(0.0))];
1532        let t = Transition::ms(1000.0).easing(Easing::Linear);
1533        a.set_time(0.25);
1534        a.begin_frame(frame.next());
1535        assert!((a.node(Key::ROOT).sample(&track, t, None).unwrap()[0] - 5.0).abs() < 1e-3);
1536        // Held back 250ms: reads what an undelayed node read at 0.
1537        a.set_time(0.25);
1538        a.begin_frame(frame.next());
1539        assert!(
1540            (a.node(Key::ROOT)
1541                .sample(&track, t.delay(250.0), None)
1542                .unwrap()[0])
1543                .abs()
1544                < 1e-3
1545        );
1546        // Ease-in per segment: a quarter of the way through the first leg
1547        // sits well below linear's 5.
1548        a.set_time(0.125);
1549        a.begin_frame(frame.next());
1550        let v = a
1551            .node(Key::ROOT)
1552            .sample(&track, t.easing(Easing::EaseIn), None)
1553            .unwrap()[0];
1554        assert!(v > 0.0 && v < 2.0, "{v}");
1555        // Stops that don't start at 0 hold the first value until they do.
1556        let late = [(0.5, one(3.0)), (1.0, one(9.0))];
1557        a.set_time(0.1);
1558        a.begin_frame(frame.next());
1559        assert_eq!(a.node(Key::ROOT).sample(&late, t, None).unwrap()[0], 3.0);
1560    }
1561
1562    #[test]
1563    fn keyframes_need_a_clock_and_a_duration() {
1564        let mut a = AnimStore::default();
1565        let mut frame = Frames(0);
1566        let track = [(0.0, one(0.0)), (1.0, one(1.0))];
1567        a.begin_frame(frame.next());
1568        assert!(
1569            a.node(Key::ROOT)
1570                .sample(&track, Transition::ms(100.0), None)
1571                .is_none()
1572        );
1573        assert!(!a.animating());
1574        a.set_time(1.0);
1575        a.begin_frame(frame.next());
1576        assert!(
1577            a.node(Key::ROOT)
1578                .sample(&track, Transition::ms(0.0), None)
1579                .is_none()
1580        );
1581        assert!(
1582            a.node(Key::ROOT)
1583                .sample(&[], Transition::ms(100.0), None)
1584                .is_none()
1585        );
1586        assert!(!a.animating());
1587    }
1588
1589    #[test]
1590    fn no_clock_means_no_animation() {
1591        let mut a = AnimStore::default();
1592        let mut frame = Frames(0);
1593        let k = Key::ROOT.str("x");
1594        let t = Transition::ms(100.0);
1595        a.begin_frame(frame.next());
1596        a.drive(k, Slot::Width, None, one(0.0), t, true);
1597        a.begin_frame(frame.next());
1598        assert_eq!(a.drive(k, Slot::Width, None, one(100.0), t, true)[0], 100.0);
1599        assert!(!a.animating());
1600    }
1601}