Skip to main content

kui_core/
anim.rs

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