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