1
//! DOM-morph animation: interpolation core, FLIP geometry, and the keyed
2
//! animation store.
3
//!
4
//! This is the home `scripts/ANIMATION_SHADER_DESIGN.md` calls for and that
5
//! `scripts/ARCHITECTURE.md` has referenced for a while without it existing.
6
//!
7
//! # The model
8
//!
9
//! Animation here is **not** a new scheduler. It compiles down to keyed timer
10
//! callbacks writing into the top cascade layer (`user_overridden_properties`),
11
//! exactly as the existing caret/selection tweens do. This module owns only the
12
//! parts that are pure math plus the bookkeeping that has to survive across
13
//! frames:
14
//!
15
//! * [`Spring`] / [`AnimChannel`] — how a scalar gets from `from` to `to`, with
16
//!   **interruption as a first-class operation** ([`AnimChannel::retarget`]).
17
//! * [`flip`] — the First/Last inversion that turns a layout change into a
18
//!   composited transform, so a move costs a GPU key rather than a relayout.
19
//! * [`AnimationManager`] — the keyed store. Keys are reconciliation identities,
20
//!   which is what makes retargeting possible at all: A→B→C finds the in-flight
21
//!   state instead of starting a second animation over the top of the first.
22
//!
23
//! # Why springs rather than only easing curves
24
//!
25
//! A cubic-bezier is a function of *normalised time*, so interrupting one and
26
//! starting another discards the current velocity — the element visibly snaps.
27
//! A spring integrates from the current `(value, velocity)`, so a retarget mid
28
//! flight continues smoothly. That is the whole reason drag-release and
29
//! rapid A→B→C feel right.
30
//!
31
//! `Spring` is a real `#[repr(C)]` variant of `AnimationInterpolationFunction`,
32
//! so a spring is expressible in CSS and across the C ABI like any other timing
33
//! function — not a Rust-only concept bolted beside one. [`SpringCurve`] lives
34
//! in `azul-css` next to the enum it belongs to and is re-exported here as
35
//! [`Spring`], so there is exactly one definition of a type that crosses the
36
//! ABI.
37
//!
38
//! That variant is unlike the others in one way worth knowing: it has **no
39
//! duration**, so it cannot be evaluated at a normalised `t`. Anything holding a
40
//! timeline must branch on `is_spring()`; `AnimChannel` does this by dispatching
41
//! on [`Interp`], which is why springs never reach [`ease`].
42
//!
43
//! [`SpringCurve`]: azul_css::props::basic::animation::SpringCurve
44
//!
45
//! # no_std
46
//!
47
//! `alloc` only. Note the module-level `use alloc::vec::Vec` — a body-level
48
//! `use` would not be in scope for function *signatures*, which is precisely
49
//! how a `--no-default-features` build was broken once already.
50

            
51
use alloc::{collections::BTreeMap, vec::Vec};
52

            
53
use azul_css::props::basic::animation::AnimationInterpolationFunction;
54

            
55
use crate::{
56
    diff::{calculate_reconciliation_key, NodeMove},
57
    dom::NodeData,
58
    geom::LogicalRect,
59
    id::NodeId,
60
    styled_dom::NodeHierarchyItem,
61
};
62

            
63
/// Mass-spring-damper parameters.
64
///
65
/// Re-exported from `azul_css` so there is ONE definition: the spring is part
66
/// of `AnimationInterpolationFunction`, which crosses the C ABI, and a second
67
/// structurally-identical `Spring` in core would be a silent ABI trap the day
68
/// one of them gained a field.
69
///
70
/// Integrated with semi-implicit (symplectic) Euler, which is the cheap,
71
/// stable choice for interactive springs: it does not blow up at the frame
72
/// rates a UI actually sees, and unlike the closed-form solution it needs no
73
/// case split on the damping regime.
74
pub use azul_css::props::basic::animation::SpringCurve as Spring;
75

            
76

            
77
/// How a channel is driven.
78
///
79
/// Wraps [`AnimationInterpolationFunction`] rather than extending it — see the
80
/// module docs on the ABI.
81
#[derive(Debug, Clone, Copy, PartialEq)]
82
pub enum Interp {
83
    /// Duration-based easing. Not interruptible without a visible discontinuity.
84
    Curve {
85
        /// The CSS easing curve.
86
        function: AnimationInterpolationFunction,
87
        /// Total duration in seconds. Zero means "apply instantly".
88
        duration_secs: f32,
89
    },
90
    /// Physics-based. Interruptible with velocity continuity.
91
    Spring(Spring),
92
}
93

            
94
impl Default for Interp {
95
    fn default() -> Self {
96
        Self::Spring(Spring::SMOOTH)
97
    }
98
}
99

            
100
/// One animated scalar.
101
///
102
/// Compose these for anything richer: a FLIP move is four channels (translate
103
/// x/y, scale x/y), a fade is one.
104
#[derive(Debug, Clone, Copy, PartialEq)]
105
pub struct AnimChannel {
106
    /// Where the value started. Re-seeded on every retarget.
107
    pub from: f32,
108
    /// Where it is heading.
109
    pub to: f32,
110
    /// The value right now — what the caller writes into the cascade.
111
    pub current: f32,
112
    /// Units per second. Carried across retargets; that is the point.
113
    pub velocity: f32,
114
    /// Seconds since this leg started (curve mode only).
115
    pub elapsed_secs: f32,
116
    /// How it is driven.
117
    pub interp: Interp,
118
    /// Latched once the channel arrives, so `is_finished` cannot flicker.
119
    finished: bool,
120
}
121

            
122
impl AnimChannel {
123
    /// A channel that eases `from → to` over `duration_secs`.
124
    #[must_use]
125
118
    pub const fn curve(
126
118
        from: f32,
127
118
        to: f32,
128
118
        function: AnimationInterpolationFunction,
129
118
        duration_secs: f32,
130
118
    ) -> Self {
131
118
        Self {
132
118
            from,
133
118
            to,
134
118
            current: from,
135
118
            velocity: 0.0,
136
118
            elapsed_secs: 0.0,
137
118
            interp: Interp::Curve { function, duration_secs },
138
118
            finished: false,
139
118
        }
140
118
    }
141

            
142
    /// A channel that springs `from → to`.
143
    #[must_use]
144
214
    pub const fn spring(from: f32, to: f32, spring: Spring) -> Self {
145
214
        Self {
146
214
            from,
147
214
            to,
148
214
            current: from,
149
214
            velocity: 0.0,
150
214
            elapsed_secs: 0.0,
151
214
            interp: Interp::Spring(spring),
152
214
            finished: false,
153
214
        }
154
214
    }
155

            
156
    /// Advance by `dt` seconds and return the new current value.
157
14755
    pub fn tick(&mut self, dt: f32) -> f32 {
158
14755
        if self.finished {
159
2667
            return self.current;
160
12088
        }
161
12088
        match self.interp {
162
11148
            Interp::Curve { function, duration_secs } => {
163
11148
                if duration_secs <= 0.0 {
164
1
                    self.current = self.to;
165
1
                    self.velocity = 0.0;
166
1
                    self.finished = true;
167
1
                    return self.current;
168
11147
                }
169
11147
                self.elapsed_secs += dt.max(0.0);
170
11147
                let linear_t = (self.elapsed_secs / duration_secs).clamp(0.0, 1.0);
171
11147
                let eased = ease(function, linear_t);
172
11147
                let previous = self.current;
173
                // Explicit FP on purpose: mul_add fuses only with +fma and
174
                // changes results bit-for-bit; animation sampling must stay
175
                // bit-reproducible. (clippy::suboptimal_flops)
176
                #[allow(clippy::suboptimal_flops)]
177
11147
                {
178
11147
                    self.current = self.from + (self.to - self.from) * eased;
179
11147
                }
180
                // Track velocity even on curves: if this channel is later
181
                // retargeted onto a spring, the handover is continuous.
182
11147
                self.velocity = if dt > 0.0 { (self.current - previous) / dt } else { 0.0 };
183
11147
                if linear_t >= 1.0 {
184
116
                    self.current = self.to;
185
116
                    self.finished = true;
186
11031
                }
187
            }
188
940
            Interp::Spring(spring) => {
189
940
                let (value, velocity) = spring.step(self.current, self.to, self.velocity, dt);
190
940
                self.current = value;
191
940
                self.velocity = velocity;
192
940
                if spring.is_settled(value, self.to, velocity) {
193
132
                    self.current = self.to;
194
132
                    self.velocity = 0.0;
195
132
                    self.finished = true;
196
819
                }
197
            }
198
        }
199
12087
        self.current
200
14755
    }
201

            
202
    /// Whether this channel has arrived and can be dropped.
203
    #[must_use]
204
3179
    pub const fn is_finished(&self) -> bool {
205
3179
        self.finished
206
3179
    }
207

            
208
    /// Aim at a new target **without losing the current value or velocity**.
209
    ///
210
    /// This is the operation a browser's WAAPI cannot express: there, a new
211
    /// animation replaces the old one and the element jumps to the new `from`.
212
    /// Here A→B→C mid-flight continues from wherever it actually is, at the
213
    /// speed it is actually travelling.
214
99
    pub fn retarget(&mut self, new_to: f32) {
215
99
        if (self.to - new_to).abs() < f32::EPSILON && !self.finished {
216
58
            return; // already heading there; do not restart the clock
217
41
        }
218
41
        self.from = self.current;
219
41
        self.to = new_to;
220
41
        self.elapsed_secs = 0.0;
221
41
        self.finished = false;
222
        // `velocity` is deliberately NOT reset — that is the whole feature.
223
99
    }
224
}
225

            
226
/// Evaluate a CSS easing curve at `t ∈ [0, 1]`.
227
///
228
/// `Ease` and the cubic-beziers use the same evaluator; the named curves are
229
/// their standard control points.
230
#[must_use]
231
11180
pub fn ease(function: AnimationInterpolationFunction, t: f32) -> f32 {
232
11180
    let t = t.clamp(0.0, 1.0);
233
11180
    match function {
234
11152
        AnimationInterpolationFunction::Linear => t,
235
        // The CSS keyword control points.
236
4
        AnimationInterpolationFunction::Ease => cubic_bezier_y(0.25, 0.1, 0.25, 1.0, t),
237
5
        AnimationInterpolationFunction::EaseIn => cubic_bezier_y(0.42, 0.0, 1.0, 1.0, t),
238
5
        AnimationInterpolationFunction::EaseOut => cubic_bezier_y(0.0, 0.0, 0.58, 1.0, t),
239
        // `Spring(_)` shares the ease-in-out body ON PURPOSE: a spring has
240
        // no `t` — reaching here means a caller put a spring where a
241
        // duration-based curve was expected (`AnimChannel` dispatches on
242
        // `Interp`, so a spring never takes this path — it integrates in
243
        // `Spring::step` from its live (value, velocity)). The stand-in
244
        // matches `AnimationInterpolationFunction::get_curve`, so the two
245
        // disagree nowhere, and it degrades to plausible motion rather than
246
        // a panic or a frozen element.
247
        AnimationInterpolationFunction::EaseInOut
248
        | AnimationInterpolationFunction::Spring(_) => {
249
14
            cubic_bezier_y(0.42, 0.0, 0.58, 1.0, t)
250
        }
251
        // A CSS timing bezier is normalised to P0 = (0,0), P3 = (1,1), so only
252
        // the two control points carry information.
253
        AnimationInterpolationFunction::CubicBezier(curve) => {
254
            cubic_bezier_y(curve.ctrl_1.x, curve.ctrl_1.y, curve.ctrl_2.x, curve.ctrl_2.y, t)
255
        }
256
    }
257
11180
}
258

            
259
/// y of a CSS timing bezier at parameter x, with P0 = (0,0) and P3 = (1,1).
260
///
261
/// CSS timing functions are parameterised by x (progress), not by the curve's
262
/// own parameter, so x must be inverted first. Newton converges in a couple of
263
/// iterations for the well-behaved curves; the bisection fallback keeps it
264
/// correct for curves with near-zero derivative.
265
28
fn cubic_bezier_y(x1: f32, y1: f32, x2: f32, y2: f32, x: f32) -> f32 {
266
    const NEWTON_ITERATIONS: usize = 4;
267
    const BISECTION_ITERATIONS: usize = 12;
268
    const EPSILON: f32 = 1e-5;
269

            
270
    // Explicit FP on purpose: mul_add fuses only with +fma and changes
271
    // results bit-for-bit; easing must stay bit-reproducible across builds.
272
    // (clippy::suboptimal_flops)
273
    #[allow(clippy::suboptimal_flops)]
274
29
    let bezier = |a: f32, b: f32, t: f32| {
275
29
        let inv = 1.0 - t;
276
29
        3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t
277
29
    };
278
    #[allow(clippy::suboptimal_flops)]
279
28
    let bezier_slope = |a: f32, b: f32, t: f32| {
280
13
        let inv = 1.0 - t;
281
13
        3.0 * inv * inv * a + 6.0 * inv * t * (b - a) + 3.0 * t * t * (1.0 - b)
282
13
    };
283

            
284
28
    if x <= 0.0 {
285
10
        return 0.0;
286
18
    }
287
18
    if x >= 1.0 {
288
10
        return 1.0;
289
8
    }
290

            
291
8
    let mut t = x;
292
21
    for _ in 0..NEWTON_ITERATIONS {
293
21
        let error = bezier(x1, x2, t) - x;
294
21
        if error.abs() < EPSILON {
295
8
            return bezier(y1, y2, t);
296
13
        }
297
13
        let slope = bezier_slope(x1, x2, t);
298
13
        if slope.abs() < EPSILON {
299
            break;
300
13
        }
301
13
        t -= error / slope;
302
    }
303

            
304
    let (mut low, mut high) = (0.0_f32, 1.0_f32);
305
    let mut t = x;
306
    for _ in 0..BISECTION_ITERATIONS {
307
        let value = bezier(x1, x2, t);
308
        if (value - x).abs() < EPSILON {
309
            break;
310
        }
311
        if value < x {
312
            low = t;
313
        } else {
314
            high = t;
315
        }
316
        t = (low + high) * 0.5;
317
    }
318
    bezier(y1, y2, t)
319
28
}
320

            
321
/// The inverted transform of a FLIP move.
322
///
323
/// Applied to an element already laid out at Last, it makes the element *appear*
324
/// at First. Animating these four numbers to identity plays the move on the GPU
325
/// with no relayout — which is why a move costs a transform key rather than a
326
/// per-frame re-solve.
327
#[derive(Debug, Clone, Copy, PartialEq, Default)]
328
#[repr(C)]
329
pub struct FlipTransform {
330
    /// Horizontal offset, logical px.
331
    pub translate_x: f32,
332
    /// Vertical offset, logical px.
333
    pub translate_y: f32,
334
    /// Horizontal scale, 1.0 = unchanged.
335
    pub scale_x: f32,
336
    /// Vertical scale, 1.0 = unchanged.
337
    pub scale_y: f32,
338
}
339

            
340
impl FlipTransform {
341
    /// The no-op transform.
342
    pub const IDENTITY: Self =
343
        Self { translate_x: 0.0, translate_y: 0.0, scale_x: 1.0, scale_y: 1.0 };
344

            
345
    /// Whether this is close enough to identity that emitting it is pointless.
346
    #[must_use]
347
101
    pub fn is_identity(&self) -> bool {
348
101
        self.translate_x.abs() < 0.01
349
84
            && self.translate_y.abs() < 0.01
350
76
            && (self.scale_x - 1.0).abs() < 0.001
351
76
            && (self.scale_y - 1.0).abs() < 0.001
352
101
    }
353
}
354

            
355
/// Compute the FLIP inversion from a First (pre-change) and Last (post-change) rect.
356
///
357
/// Degenerate Last extents fall back to scale 1 rather than producing infinities:
358
/// a zero-sized target is a collapsed or not-yet-measured node, and a NaN
359
/// transform would poison the display list.
360
/// POSITION ONLY — a USER ruling (2026-08-17), not an omission, and also what
361
/// the design doc's Move row specifies ("FLIP: transform Δ→identity"). A
362
/// matched node whose size changed has already RELAYOUTED at its final size;
363
/// scaling it from the old size squashes freshly laid-out content (text set
364
/// for the wide layout rendered at half width) for the whole flight. Content
365
/// never distorts unless the user explicitly animates CSS `transform`, which
366
/// is a pure transform without relayout — there, distortion is the point.
367
/// A move travels; it does not morph.
368
#[must_use]
369
106
pub fn flip(first: LogicalRect, last: LogicalRect) -> FlipTransform {
370
106
    let _ = (first.size, last.size); // sizes are layout's job, not the animation's
371
106
    FlipTransform {
372
106
        translate_x: first.origin.x - last.origin.x,
373
106
        translate_y: first.origin.y - last.origin.y,
374
106
        scale_x: 1.0,
375
106
        scale_y: 1.0,
376
106
    }
377
106
}
378

            
379
/// Which presence class an animation belongs to.
380
///
381
/// These map 1:1 onto what the diff already reports: unmatched-new is Enter,
382
/// unmatched-old is Exit, and a `NodeMove` pair whose geometry changed is Move.
383
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384
pub enum AnimClass {
385
    /// Node exists in the new DOM only.
386
    Enter,
387
    /// Node existed in the old DOM only. Needs exit-retention to be visible.
388
    Exit,
389
    /// Node exists in both, at different geometry.
390
    Move,
391
}
392

            
393
/// Identity of an animation across frames.
394
///
395
/// This is deliberately **not** a `NodeId`: node ids are array positions and are
396
/// not stable across a re-produce, so keying on them would make every frame look
397
/// like a fresh animation and retargeting would never fire. The reconciliation
398
/// key (`.with_key()` / `#id` / structural hash) is what survives.
399
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
400
#[repr(C)]
401
pub struct AnimKey(pub u64);
402

            
403
/// One in-flight animation: the FLIP channels plus opacity.
404
#[derive(Debug, Clone, Copy, PartialEq)]
405
pub struct ActiveAnim {
406
    /// What kind of presence change started this.
407
    pub class: AnimClass,
408
    /// Horizontal offset channel.
409
    pub translate_x: AnimChannel,
410
    /// Vertical offset channel.
411
    pub translate_y: AnimChannel,
412
    /// Horizontal scale channel.
413
    pub scale_x: AnimChannel,
414
    /// Vertical scale channel.
415
    pub scale_y: AnimChannel,
416
    /// Opacity channel.
417
    pub opacity: AnimChannel,
418
}
419

            
420
impl ActiveAnim {
421
    /// A move: start at the FLIP inversion, animate to identity.
422
    #[must_use]
423
30
    pub const fn move_from_flip(flip: FlipTransform, interp: Interp) -> Self {
424
30
        Self {
425
30
            class: AnimClass::Move,
426
30
            translate_x: channel(flip.translate_x, 0.0, interp),
427
30
            translate_y: channel(flip.translate_y, 0.0, interp),
428
30
            scale_x: channel(flip.scale_x, 1.0, interp),
429
30
            scale_y: channel(flip.scale_y, 1.0, interp),
430
30
            opacity: channel(1.0, 1.0, interp),
431
30
        }
432
30
    }
433

            
434
    /// An enter: SLIDE IN from `(from_x, from_y)` to identity, full opacity,
435
    /// full size.
436
    ///
437
    /// Was fade+scale-up; changed by the same USER ruling as [`flip`]:
438
    /// presence changes travel, content never distorts or ghosts. The offset
439
    /// is the caller's choice — the engine default slides from the nearest
440
    /// viewport edge, so a sidebar re-opens the way it left.
441
    #[must_use]
442
12
    pub const fn enter_slide(from_x: f32, from_y: f32, interp: Interp) -> Self {
443
12
        Self {
444
12
            class: AnimClass::Enter,
445
12
            translate_x: channel(from_x, 0.0, interp),
446
12
            translate_y: channel(from_y, 0.0, interp),
447
12
            scale_x: channel(1.0, 1.0, interp),
448
12
            scale_y: channel(1.0, 1.0, interp),
449
12
            opacity: channel(1.0, 1.0, interp),
450
12
        }
451
12
    }
452

            
453
    /// An exit: SLIDE OUT from identity to `(to_x, to_y)`, full opacity,
454
    /// full size. Only visible with exit-retention.
455
    ///
456
    /// Was fade+shrink-in-place; same USER ruling as [`flip`]: a departing
457
    /// sidebar slides away to its edge — it does not dissolve.
458
    ///
459
    /// Reverse a presence animation IN FLIGHT: the channels retarget from
460
    /// their CURRENT values (velocity preserved — `Channel::retarget`'s whole
461
    /// feature) toward the new destination. `Exit` + a slide target turns an
462
    /// entering node around; `Enter` + identity catches an exiting node
463
    /// (the remount-mid-exit catch: the zombie is dropped and the LIVE node
464
    /// travels home from wherever the exit had carried it).
465
12
    pub fn retarget_presence(&mut self, class: AnimClass, to_x: f32, to_y: f32) {
466
12
        self.class = class;
467
12
        self.translate_x.retarget(to_x);
468
12
        self.translate_y.retarget(to_y);
469
12
        self.scale_x.retarget(1.0);
470
12
        self.scale_y.retarget(1.0);
471
12
        self.opacity.retarget(1.0);
472
12
    }
473

            
474
    #[must_use]
475
23
    pub const fn exit_slide(to_x: f32, to_y: f32, interp: Interp) -> Self {
476
23
        Self {
477
23
            class: AnimClass::Exit,
478
23
            translate_x: channel(0.0, to_x, interp),
479
23
            translate_y: channel(0.0, to_y, interp),
480
23
            scale_x: channel(1.0, 1.0, interp),
481
23
            scale_y: channel(1.0, 1.0, interp),
482
23
            opacity: channel(1.0, 1.0, interp),
483
23
        }
484
23
    }
485

            
486
    /// Advance every channel by `dt` seconds.
487
2917
    pub fn tick(&mut self, dt: f32) {
488
2917
        self.translate_x.tick(dt);
489
2917
        self.translate_y.tick(dt);
490
2917
        self.scale_x.tick(dt);
491
2917
        self.scale_y.tick(dt);
492
2917
        self.opacity.tick(dt);
493
2917
    }
494

            
495
    /// True once every channel has arrived.
496
    #[must_use]
497
2948
    pub const fn is_finished(&self) -> bool {
498
2948
        self.translate_x.is_finished()
499
37
            && self.translate_y.is_finished()
500
31
            && self.scale_x.is_finished()
501
31
            && self.scale_y.is_finished()
502
31
            && self.opacity.is_finished()
503
2948
    }
504

            
505
    /// The transform to write this frame.
506
    #[must_use]
507
708
    pub const fn current_transform(&self) -> FlipTransform {
508
708
        FlipTransform {
509
708
            translate_x: self.translate_x.current,
510
708
            translate_y: self.translate_y.current,
511
708
            scale_x: self.scale_x.current,
512
708
            scale_y: self.scale_y.current,
513
708
        }
514
708
    }
515

            
516
    /// The opacity to write this frame.
517
    #[must_use]
518
696
    pub const fn current_opacity(&self) -> f32 {
519
696
        self.opacity.current
520
696
    }
521

            
522
    /// Re-aim at a new FLIP target, preserving position and velocity.
523
9
    pub fn retarget_move(&mut self, flip: FlipTransform) {
524
        // The NEW inversion is where the element must appear to start, so the
525
        // channels are re-seeded toward identity from wherever they are now.
526
9
        self.translate_x.retarget(0.0);
527
9
        self.translate_y.retarget(0.0);
528
9
        self.scale_x.retarget(1.0);
529
9
        self.scale_y.retarget(1.0);
530
        // Fold the freshly measured offset in, rather than snapping to it.
531
9
        self.translate_x.current += flip.translate_x;
532
9
        self.translate_y.current += flip.translate_y;
533
9
    }
534
}
535

            
536
325
const fn channel(from: f32, to: f32, interp: Interp) -> AnimChannel {
537
325
    match interp {
538
115
        Interp::Curve { function, duration_secs } => {
539
115
            AnimChannel::curve(from, to, function, duration_secs)
540
        }
541
210
        Interp::Spring(spring) => AnimChannel::spring(from, to, spring),
542
    }
543
325
}
544

            
545
/// The keyed store of in-flight animations.
546
///
547
/// Sibling to `GpuStateManager`. Holds only what must outlive a frame; the
548
/// actual writing of values happens in the layout crate, which owns the cascade.
549
#[derive(Debug, Clone, Default)]
550
pub struct AnimationManager {
551
    active: BTreeMap<AnimKey, ActiveAnim>,
552
}
553

            
554
impl AnimationManager {
555
    /// An empty manager.
556
    #[must_use]
557
24013
    pub const fn new() -> Self {
558
24013
        Self { active: BTreeMap::new() }
559
24013
    }
560

            
561
    /// How many animations are in flight.
562
    #[must_use]
563
87
    pub fn len(&self) -> usize {
564
87
        self.active.len()
565
87
    }
566

            
567
    /// Whether anything is animating (i.e. whether a frame needs scheduling).
568
    #[must_use]
569
2187
    pub fn is_empty(&self) -> bool {
570
2187
        self.active.is_empty()
571
2187
    }
572

            
573
    /// Start a move, or **retarget** one already in flight under this key.
574
    ///
575
    /// This is the entry point that makes rapid A→B→C smooth: the second call
576
    /// does not stack a new animation on the first, it redirects it.
577
39
    pub fn start_or_retarget_move(&mut self, key: AnimKey, flip: FlipTransform, interp: Interp) {
578
39
        if let Some(existing) = self.active.get_mut(&key) {
579
9
            existing.retarget_move(flip);
580
30
        } else {
581
30
            self.active.insert(key, ActiveAnim::move_from_flip(flip, interp));
582
30
        }
583
39
    }
584

            
585
    /// Start an enter animation, unless this key is already animating.
586
13
    pub fn start_enter(&mut self, key: AnimKey, from: (f32, f32), interp: Interp) {
587
13
        self.active
588
13
            .entry(key)
589
13
            .or_insert_with(|| ActiveAnim::enter_slide(from.0, from.1, interp));
590
13
    }
591

            
592
    /// Start an exit animation. An exit always WINS — the node is leaving, so
593
    /// continuing toward a layout position it will never occupy is wrong —
594
    /// but it does not RESTART: if an animation is already in flight under
595
    /// this key (a node unmounted mid-enter, or mid-move), the channels
596
    /// RETARGET from their current value with velocity preserved, so the
597
    /// node turns around instead of snapping to its laid-out position first.
598
35
    pub fn start_exit(&mut self, key: AnimKey, to: (f32, f32), interp: Interp) {
599
35
        match self.active.get_mut(&key) {
600
12
            Some(anim) => anim.retarget_presence(AnimClass::Exit, to.0, to.1),
601
23
            None => {
602
23
                self.active.insert(key, ActiveAnim::exit_slide(to.0, to.1, interp));
603
23
            }
604
        }
605
35
    }
606

            
607
    /// Mutable access to an in-flight animation — the mid-flight-catch hook.
608
22
    pub fn get_mut(&mut self, key: AnimKey) -> Option<&mut ActiveAnim> {
609
22
        self.active.get_mut(&key)
610
22
    }
611

            
612
    /// Read the current state for a key.
613
    #[must_use]
614
523
    pub fn get(&self, key: AnimKey) -> Option<&ActiveAnim> {
615
523
        self.active.get(&key)
616
523
    }
617

            
618
    /// Every in-flight animation with its key.
619
    ///
620
    /// The compositor needs this each frame to turn identity-keyed animation
621
    /// state into per-`NodeId` GPU values; it cannot ask for keys it does not
622
    /// already know about.
623
862
    pub fn iter(&self) -> impl Iterator<Item = (AnimKey, &ActiveAnim)> {
624
862
        self.active.iter().map(|(k, v)| (*k, v))
625
862
    }
626

            
627
    /// Advance every animation and drop the ones that arrived.
628
    ///
629
    /// Returns the keys that finished this tick, so the caller can release the
630
    /// GPU keys and — for exits — drop the retained subtree exactly once.
631
5295
    pub fn tick(&mut self, dt: f32) -> Vec<AnimKey> {
632
5295
        let mut finished = Vec::new();
633
8212
        for (key, anim) in &mut self.active {
634
2917
            anim.tick(dt);
635
2917
            if anim.is_finished() {
636
31
                finished.push(*key);
637
2886
            }
638
        }
639
5326
        for key in &finished {
640
31
            self.active.remove(key);
641
31
        }
642
5295
        finished
643
5295
    }
644

            
645
    /// Drop an animation without letting it finish (e.g. its node vanished).
646
    pub fn cancel(&mut self, key: AnimKey) -> Option<ActiveAnim> {
647
        self.active.remove(&key)
648
    }
649
}
650

            
651
/// Turn the diff's correspondence map into `(key, First, Last)` triples.
652
///
653
/// `node_moves` is what `reconcile_dom` already produced: old `NodeId` ->
654
/// new `NodeId` for every node that survived the re-produce. This pairs each
655
/// one with its pre-swap and post-solve geometry so [`seed_moves`] can decide
656
/// what actually moved.
657
///
658
/// Geometry is fetched through closures rather than a map parameter because the
659
/// two rects live in different crates and different *phases*: First comes from
660
/// the previous frame's `LayoutCache` (still alive at the diff seam), Last from
661
/// the freshly solved layout, which does not exist until well after that seam.
662
/// Passing accessors lets the caller bridge that gap without core depending on
663
/// the layout crate.
664
///
665
/// The key is the **reconciliation key**, the same identity the diff matched on
666
/// — not the `NodeId`. A `NodeId` is an array position: it can change for a
667
/// node that did not move, and can be reused by an unrelated node. Keying an
668
/// animation store on it would make retargeting fire on the wrong element, or
669
/// not at all.
670
///
671
/// Pairs missing either rect are dropped: a node with no previous geometry has
672
/// nothing to fly from, and one with no new geometry is not on screen to fly to.
673
25
pub fn correspondences_from_moves<F, L>(
674
25
    node_moves: &[NodeMove],
675
25
    new_node_data: &[NodeData],
676
25
    new_hierarchy: &[NodeHierarchyItem],
677
25
    first_rect: F,
678
25
    last_rect: L,
679
25
) -> Vec<(AnimKey, LogicalRect, LogicalRect)>
680
25
where
681
25
    F: Fn(NodeId) -> Option<LogicalRect>,
682
25
    L: Fn(NodeId) -> Option<LogicalRect>,
683
{
684
25
    let mut out = Vec::new();
685
231
    for m in node_moves {
686
206
        let (Some(first), Some(last)) = (first_rect(m.old_node_id), last_rect(m.new_node_id))
687
        else {
688
110
            continue;
689
        };
690
96
        if m.new_node_id.index() >= new_node_data.len() {
691
            continue; // stale correspondence; the new tree does not have this node
692
96
        }
693
96
        let key = AnimKey(calculate_reconciliation_key(
694
96
            new_node_data,
695
96
            new_hierarchy,
696
96
            m.new_node_id,
697
96
        ));
698
96
        out.push((key, first, last));
699
    }
700
25
    out
701
25
}
702

            
703
/// The `AnimKey` → current `NodeId` mapping for this frame's correspondences.
704
///
705
/// Animation state is keyed by reconciliation identity so it can outlive a
706
/// rebuild, but the compositor writes GPU values per `NodeId`. Something has to
707
/// bridge the two, and it has to be rebuilt every layout: the key is stable
708
/// across rebuilds precisely because the `NodeId` is not.
709
///
710
/// Kept separate from [`correspondences_from_moves`] rather than folded into its
711
/// return type, because the two have different lifetimes — the correspondences
712
/// are consumed once at seed time, this map is read every frame until the
713
/// animation settles.
714
#[must_use]
715
81
pub fn anim_keys_for_moves(
716
81
    node_moves: &[NodeMove],
717
81
    new_node_data: &[NodeData],
718
81
    new_hierarchy: &[NodeHierarchyItem],
719
81
) -> Vec<(AnimKey, NodeId)> {
720
81
    node_moves
721
81
        .iter()
722
204
        .filter(|m| m.new_node_id.index() < new_node_data.len())
723
204
        .map(|m| {
724
204
            (
725
204
                AnimKey(calculate_reconciliation_key(
726
204
                    new_node_data,
727
204
                    new_hierarchy,
728
204
                    m.new_node_id,
729
204
                )),
730
204
                m.new_node_id,
731
204
            )
732
204
        })
733
81
        .collect()
734
81
}
735

            
736
/// Seed (or retarget) a FLIP move for every correspondence whose geometry moved.
737
///
738
/// This is the engine entry point for Phase 1: the caller hands over the
739
/// old↔new correspondences the diff already produced, paired with the First
740
/// (pre-swap) and Last (post-solve) rects, and every pair that actually moved
741
/// becomes a composited transform animation. Pairs that did not move are
742
/// skipped — seeding an identity FLIP would allocate a GPU key and animate
743
/// nothing.
744
///
745
/// Returns how many animations were started or retargeted, which is exactly the
746
/// number of nodes that need a GPU transform key this frame.
747
27
pub fn seed_moves<I>(manager: &mut AnimationManager, correspondences: I, interp: Interp) -> usize
748
27
where
749
27
    I: IntoIterator<Item = (AnimKey, LogicalRect, LogicalRect)>,
750
{
751
27
    let mut seeded = 0;
752
126
    for (key, first, last) in correspondences {
753
99
        let transform = flip(first, last);
754
99
        if transform.is_identity() {
755
74
            continue;
756
25
        }
757
25
        manager.start_or_retarget_move(key, transform, interp);
758
25
        seeded += 1;
759
    }
760
27
    seeded
761
27
}
762

            
763
#[cfg(test)]
764
mod tests {
765
    use super::*;
766
    use crate::geom::{LogicalPosition, LogicalRect, LogicalSize};
767

            
768
    /// `Spring` is a real `AnimationInterpolationFunction` variant now, so it
769
    /// must survive the C-ABI enum's own accessors rather than panicking or
770
    /// silently answering as some other curve.
771
    #[test]
772
1
    fn spring_is_a_first_class_interpolation_function() {
773
1
        let f = AnimationInterpolationFunction::Spring(Spring::SNAPPY);
774
1
        assert!(f.is_spring());
775
1
        assert!(!AnimationInterpolationFunction::EaseInOut.is_spring());
776
        // No duration to evaluate against: it answers as the documented
777
        // ease-in-out stand-in, and `get_curve`/`ease` must not disagree.
778
1
        assert_eq!(f.get_curve(), AnimationInterpolationFunction::EaseInOut.get_curve());
779
6
        for t in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
780
5
            assert!((ease(f, t) - ease(AnimationInterpolationFunction::EaseInOut, t)).abs() < 1e-6);
781
        }
782
1
    }
783

            
784
23
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
785
23
        LogicalRect {
786
23
            origin: LogicalPosition::new(x, y),
787
23
            size: LogicalSize::new(w, h),
788
23
        }
789
23
    }
790

            
791
    #[test]
792
1
    fn flip_inverts_a_pure_translation() {
793
        // Moved right 100 and down 50, same size: the inversion must put it back.
794
1
        let f = flip(rect(0.0, 0.0, 10.0, 10.0), rect(100.0, 50.0, 10.0, 10.0));
795
1
        assert_eq!(f.translate_x, -100.0);
796
1
        assert_eq!(f.translate_y, -50.0);
797
1
        assert_eq!(f.scale_x, 1.0);
798
1
        assert_eq!(f.scale_y, 1.0);
799
1
    }
800

            
801
    #[test]
802
1
    fn flip_never_scales_a_size_change() {
803
        // USER ruling 2026-08-17 (was `flip_inverts_a_pure_scale`, asserting
804
        // 0.5): a resized node has already RELAYOUTED at its final size, and
805
        // drawing it at half scale for the flight squashes freshly laid-out
806
        // content — a card growing from half-width to full-width rendered its
807
        // text visibly compressed for the whole transition. Size is layout's
808
        // job; the animation only travels.
809
1
        let f = flip(rect(0.0, 0.0, 50.0, 20.0), rect(0.0, 0.0, 100.0, 40.0));
810
1
        assert_eq!(f.scale_x, 1.0);
811
1
        assert_eq!(f.scale_y, 1.0);
812
1
        assert!(f.is_identity(), "same origin, changed size: nothing to animate");
813
1
    }
814

            
815
    #[test]
816
1
    fn flip_of_an_unchanged_rect_is_identity() {
817
1
        let r = rect(12.0, 34.0, 56.0, 78.0);
818
1
        assert!(flip(r, r).is_identity());
819
1
    }
820

            
821
    #[test]
822
1
    fn flip_never_produces_a_non_finite_scale() {
823
        // A collapsed target would divide by zero; the display list must never
824
        // see a NaN transform.
825
1
        let f = flip(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 0.0, 0.0));
826
1
        assert!(f.scale_x.is_finite() && f.scale_y.is_finite());
827
1
        assert_eq!(f.scale_x, 1.0);
828
1
        assert_eq!(f.scale_y, 1.0);
829
1
    }
830

            
831
    #[test]
832
1
    fn a_spring_settles_at_its_target() {
833
1
        let mut c = AnimChannel::spring(0.0, 100.0, Spring::SMOOTH);
834
68
        for _ in 0..600 {
835
68
            c.tick(1.0 / 60.0);
836
68
            if c.is_finished() {
837
1
                break;
838
67
            }
839
        }
840
1
        assert!(c.is_finished(), "spring did not settle within 10s");
841
1
        assert_eq!(c.current, 100.0);
842
1
        assert_eq!(c.velocity, 0.0);
843
1
    }
844

            
845
    #[test]
846
1
    fn a_curve_reaches_its_target_at_the_duration() {
847
        // NOTE the frame budget: 60 ticks of 1/60 sum to 0.99999994, not 1.0,
848
        // so a curve legitimately lands on the frame AFTER its nominal
849
        // duration. Asserting exact arrival at tick 60 would be asserting that
850
        // f32 addition is exact.
851
1
        let mut c = AnimChannel::curve(0.0, 10.0, AnimationInterpolationFunction::Linear, 1.0);
852
61
        for _ in 0..60 {
853
60
            c.tick(1.0 / 60.0);
854
60
        }
855
1
        assert!(
856
1
            (c.current - 10.0).abs() < 0.01,
857
            "should be at the target within a frame, got {}",
858
            c.current
859
        );
860
1
        c.tick(1.0 / 60.0);
861
1
        assert!(c.is_finished(), "curve did not finish one frame past its duration");
862
1
        assert_eq!(c.current, 10.0, "a finished curve must land exactly on `to`");
863
1
    }
864

            
865
    #[test]
866
1
    fn retarget_preserves_position_and_velocity() {
867
        // THE differentiator: mid-flight redirect must not snap back to a new
868
        // `from`, and must keep the momentum it had.
869
1
        let mut c = AnimChannel::spring(0.0, 100.0, Spring::SMOOTH);
870
11
        for _ in 0..10 {
871
10
            c.tick(1.0 / 60.0);
872
10
        }
873
1
        let value_before = c.current;
874
1
        let velocity_before = c.velocity;
875
1
        assert!(value_before > 0.0 && velocity_before > 0.0, "should be mid-flight");
876

            
877
1
        c.retarget(-50.0);
878

            
879
1
        assert_eq!(c.current, value_before, "retarget must not move the value");
880
1
        assert_eq!(c.velocity, velocity_before, "retarget must not discard velocity");
881
1
        assert_eq!(c.from, value_before);
882
1
        assert_eq!(c.to, -50.0);
883
1
        assert!(!c.is_finished());
884
1
    }
885

            
886
    #[test]
887
1
    fn retargeting_to_the_same_target_does_not_restart_the_clock() {
888
1
        let mut c = AnimChannel::curve(0.0, 10.0, AnimationInterpolationFunction::Linear, 1.0);
889
1
        c.tick(0.5);
890
1
        let elapsed = c.elapsed_secs;
891
1
        c.retarget(10.0);
892
1
        assert_eq!(c.elapsed_secs, elapsed, "a no-op retarget restarted the animation");
893
1
    }
894

            
895
    #[test]
896
1
    fn a_settled_spring_can_be_woken_by_a_retarget() {
897
1
        let mut c = AnimChannel::spring(0.0, 1.0, Spring::SNAPPY);
898
27
        for _ in 0..600 {
899
27
            c.tick(1.0 / 60.0);
900
27
            if c.is_finished() {
901
1
                break;
902
26
            }
903
        }
904
1
        assert!(c.is_finished());
905
1
        c.retarget(0.0);
906
1
        assert!(!c.is_finished(), "retarget must un-finish a settled channel");
907
1
        c.tick(1.0 / 60.0);
908
1
        assert!(c.current < 1.0, "woken channel did not move toward the new target");
909
1
    }
910

            
911
    #[test]
912
1
    fn a_huge_frame_gap_cannot_fling_a_spring() {
913
        // A stalled frame must be clamped, not integrated verbatim.
914
1
        let mut c = AnimChannel::spring(0.0, 1.0, Spring::SNAPPY);
915
1
        c.tick(10.0);
916
1
        assert!(c.current.is_finite());
917
1
        assert!(c.current.abs() < 100.0, "clamping failed: {}", c.current);
918
1
    }
919

            
920
    #[test]
921
1
    fn zero_duration_curves_apply_instantly() {
922
1
        let mut c = AnimChannel::curve(0.0, 42.0, AnimationInterpolationFunction::Ease, 0.0);
923
1
        c.tick(0.0);
924
1
        assert!(c.is_finished());
925
1
        assert_eq!(c.current, 42.0);
926
1
    }
927

            
928
    #[test]
929
1
    fn easing_curves_are_pinned_at_both_ends() {
930
5
        for f in [
931
1
            AnimationInterpolationFunction::Linear,
932
1
            AnimationInterpolationFunction::Ease,
933
1
            AnimationInterpolationFunction::EaseIn,
934
1
            AnimationInterpolationFunction::EaseOut,
935
1
            AnimationInterpolationFunction::EaseInOut,
936
        ] {
937
5
            assert_eq!(ease(f, 0.0), 0.0, "{f:?} did not start at 0");
938
5
            assert_eq!(ease(f, 1.0), 1.0, "{f:?} did not end at 1");
939
            // Out of range must clamp, not extrapolate.
940
5
            assert_eq!(ease(f, -1.0), 0.0);
941
5
            assert_eq!(ease(f, 2.0), 1.0);
942
        }
943
1
    }
944

            
945
    #[test]
946
1
    fn ease_in_starts_slower_than_linear_and_ease_out_starts_faster() {
947
1
        let t = 0.25;
948
1
        let linear = ease(AnimationInterpolationFunction::Linear, t);
949
1
        assert!(ease(AnimationInterpolationFunction::EaseIn, t) < linear);
950
1
        assert!(ease(AnimationInterpolationFunction::EaseOut, t) > linear);
951
1
    }
952

            
953
    #[test]
954
1
    fn damping_ratio_identifies_the_regime() {
955
        // Critically damped: damping = 2*sqrt(k*m).
956
1
        let critical = Spring { stiffness: 100.0, damping: 20.0, mass: 1.0 };
957
1
        assert!((critical.damping_ratio() - 1.0).abs() < 1e-5);
958
1
        assert!(Spring { stiffness: 100.0, damping: 5.0, mass: 1.0 }.damping_ratio() < 1.0);
959
1
        assert!(Spring { stiffness: 100.0, damping: 40.0, mass: 1.0 }.damping_ratio() > 1.0);
960
1
    }
961

            
962
    #[test]
963
1
    fn a_degenerate_spring_snaps_instead_of_dividing_by_zero() {
964
1
        let s = Spring { stiffness: 100.0, damping: 10.0, mass: 0.0 };
965
1
        let (value, velocity) = s.step(0.0, 5.0, 0.0, 1.0 / 60.0);
966
1
        assert_eq!(value, 5.0);
967
1
        assert_eq!(velocity, 0.0);
968
1
    }
969

            
970
    #[test]
971
1
    fn the_manager_retargets_instead_of_stacking() {
972
1
        let mut m = AnimationManager::new();
973
1
        let key = AnimKey(7);
974
1
        let interp = Interp::Spring(Spring::SMOOTH);
975

            
976
1
        m.start_or_retarget_move(key, flip(rect(0.0, 0.0, 10.0, 10.0), rect(100.0, 0.0, 10.0, 10.0)), interp);
977
1
        assert_eq!(m.len(), 1);
978
11
        for _ in 0..10 {
979
10
            m.tick(1.0 / 60.0);
980
10
        }
981
1
        let mid = m.get(key).expect("still animating").current_transform();
982

            
983
        // A second move for the SAME key must not create a second animation.
984
1
        m.start_or_retarget_move(key, flip(rect(0.0, 0.0, 10.0, 10.0), rect(200.0, 0.0, 10.0, 10.0)), interp);
985
1
        assert_eq!(m.len(), 1, "retarget created a second animation");
986
1
        let after = m.get(key).expect("still animating").current_transform();
987
1
        assert_ne!(after.translate_x, mid.translate_x, "retarget did not fold in the new offset");
988
1
    }
989

            
990
    #[test]
991
1
    fn the_manager_reports_and_drops_finished_animations() {
992
1
        let mut m = AnimationManager::new();
993
1
        m.start_enter(AnimKey(1), (-120.0, 0.0), Interp::Curve {
994
1
            function: AnimationInterpolationFunction::Linear,
995
1
            duration_secs: 0.1,
996
1
        });
997
1
        assert_eq!(m.len(), 1);
998
1
        let mut finished = Vec::new();
999
6
        for _ in 0..20 {
6
            finished = m.tick(1.0 / 60.0);
6
            if !finished.is_empty() {
1
                break;
5
            }
        }
1
        assert_eq!(finished, alloc::vec![AnimKey(1)]);
1
        assert!(m.is_empty(), "finished animation was not dropped");
1
    }
    #[test]
1
    fn an_exit_replaces_an_in_flight_move() {
        // The node is leaving; continuing toward a layout slot it will never
        // occupy would be wrong.
1
        let mut m = AnimationManager::new();
1
        let key = AnimKey(3);
1
        let interp = Interp::Spring(Spring::SMOOTH);
1
        m.start_or_retarget_move(key, flip(rect(0.0, 0.0, 10.0, 10.0), rect(50.0, 0.0, 10.0, 10.0)), interp);
1
        assert_eq!(m.get(key).map(|a| a.class), Some(AnimClass::Move));
1
        m.start_exit(key, (-120.0, 0.0), interp);
1
        assert_eq!(m.get(key).map(|a| a.class), Some(AnimClass::Exit));
1
        assert_eq!(m.len(), 1);
1
    }
    #[test]
1
    fn the_anim_key_survives_a_node_id_change() {
        // THE property the whole store depends on. A keyed node that shifts
        // position in the array (a sibling was prepended) must keep its
        // identity — otherwise the second produce looks like a brand-new
        // animation and retargeting never fires.
        use crate::dom::NodeData;
1
        let tree_a = [NodeData::create_div().with_key("hero")];
1
        let tree_b = [
1
            NodeData::create_div().with_key("spacer"),
1
            NodeData::create_div().with_key("hero"),
1
        ];
1
        let key_a = AnimKey(calculate_reconciliation_key(&tree_a, &[], NodeId::ZERO));
1
        let key_b = AnimKey(calculate_reconciliation_key(
1
            &tree_b,
1
            &[],
1
            NodeId::new(1),
1
        ));
1
        assert_eq!(key_a, key_b, "the same keyed node got two different AnimKeys");
        // And a DIFFERENT key must not collide with it.
1
        let other = AnimKey(calculate_reconciliation_key(&tree_b, &[], NodeId::ZERO));
1
        assert_ne!(key_a, other);
1
    }
    #[test]
1
    fn correspondences_drop_pairs_with_no_geometry() {
        use crate::dom::NodeData;
1
        let new_data = [NodeData::create_div().with_key("a"), NodeData::create_div().with_key("b")];
1
        let moves = [
1
            NodeMove { old_node_id: NodeId::ZERO, new_node_id: NodeId::ZERO },
1
            NodeMove { old_node_id: NodeId::new(1), new_node_id: NodeId::new(1) },
1
        ];
1
        let r = rect(0.0, 0.0, 10.0, 10.0);
1
        let out = correspondences_from_moves(
1
            &moves,
1
            &new_data,
1
            &[],
2
            |id| (id == NodeId::ZERO).then_some(r),   // only node 0 existed before
2
            |_| Some(rect(5.0, 0.0, 10.0, 10.0)),
        );
1
        assert_eq!(out.len(), 1, "a node with no previous geometry has nothing to fly from");
1
    }
    #[test]
1
    fn seed_moves_skips_nodes_that_did_not_move() {
        // An identity FLIP would take a GPU key and animate nothing.
1
        let mut m = AnimationManager::new();
1
        let stayed = rect(0.0, 0.0, 10.0, 10.0);
1
        let moved_first = rect(0.0, 0.0, 10.0, 10.0);
1
        let moved_last = rect(40.0, 0.0, 10.0, 10.0);
1
        let seeded = seed_moves(
1
            &mut m,
1
            [
1
                (AnimKey(1), stayed, stayed),
1
                (AnimKey(2), moved_first, moved_last),
1
            ],
1
            Interp::Spring(Spring::SMOOTH),
        );
1
        assert_eq!(seeded, 1, "only the node that moved should animate");
1
        assert!(m.get(AnimKey(1)).is_none());
1
        assert!(m.get(AnimKey(2)).is_some());
1
    }
    #[test]
1
    fn seed_moves_retargets_a_key_that_is_already_animating() {
        // Two produces in quick succession must not stack two animations on
        // one node — that is the visible "fighting" artefact.
1
        let mut m = AnimationManager::new();
1
        let interp = Interp::Spring(Spring::SMOOTH);
1
        seed_moves(
1
            &mut m,
1
            [(AnimKey(9), rect(0.0, 0.0, 10.0, 10.0), rect(50.0, 0.0, 10.0, 10.0))],
1
            interp,
        );
6
        for _ in 0..5 {
5
            m.tick(1.0 / 60.0);
5
        }
1
        let seeded = seed_moves(
1
            &mut m,
1
            [(AnimKey(9), rect(0.0, 0.0, 10.0, 10.0), rect(90.0, 0.0, 10.0, 10.0))],
1
            interp,
        );
1
        assert_eq!(seeded, 1);
1
        assert_eq!(m.len(), 1, "a second produce stacked a second animation");
1
    }
    #[test]
1
    fn an_enter_does_not_clobber_an_animation_already_in_flight() {
1
        let mut m = AnimationManager::new();
1
        let key = AnimKey(5);
1
        let interp = Interp::Spring(Spring::SMOOTH);
1
        m.start_exit(key, (-120.0, 0.0), interp);
1
        m.start_enter(key, (-120.0, 0.0), interp);
1
        assert_eq!(m.get(key).map(|a| a.class), Some(AnimClass::Exit));
1
    }
}