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