Skip to main content

rlvgl_core/
object_anim.rs

1//! Node-resident object animations (LPAR-06 §6).
2//!
3//! [`ObjectAnims`] is a thin walker + id allocator over animation entries
4//! stored directly on [`ObjectNode`](crate::object::ObjectNode) nodes. It
5//! reuses [`Tween`](crate::anim::Tween) math from ANIM-00 without forking it.
6//!
7//! # Binding model
8//!
9//! Animation entries live **on the target node** (not in a central registry).
10//! This is forced by the value-owned child model of [`ObjectNode`]: there are
11//! no stable node handles, and a structural-path key is fragile under sibling
12//! mutation (LPAR-06 §6.1). Each node carries an optional, lazily allocated
13//! [`NodeAnimSet`] slot, mirroring the LPAR-05 `ScrollState` slot pattern.
14//!
15//! # Detach-cancellation by construction (LPAR-06 §6.4)
16//!
17//! [`ObjectAnims::tick`] walks only the **live** tree reachable from `root`. A
18//! detached node (and its subtree) is unreachable, so its entries are never
19//! advanced, never call `apply`, never push dirty rects, and never fire
20//! `on_complete`. No identity-matching or `Detached`-listener is needed.
21//!
22//! # Hide policy (LPAR-06 §6.5)
23//!
24//! Hiding a node does **not** cancel its animations. Hidden nodes still
25//! advance on each [`ObjectAnims::tick`]; their dirty rects have no visible
26//! effect while the node is hidden (the LPAR-03 planner handles the repaint
27//! correctly).
28//!
29//! # Dirty rects
30//!
31//! Apply callbacks return `Option<Rect>`. Non-`None` rects are pushed directly
32//! into the `sink` closure passed to [`ObjectAnims::tick`], allowing callers to
33//! route them into an [`InvalidationList`](crate::invalidation::InvalidationList)
34//! or any other dirty-rect consumer.
35
36use alloc::boxed::Box;
37use alloc::vec::Vec;
38
39use crate::anim::Tween;
40use crate::object::ObjectNode;
41use crate::widget::Rect;
42
43// ---------------------------------------------------------------------------
44// ObjectAnimId
45// ---------------------------------------------------------------------------
46
47/// Opaque handle to an object-bound animation entry stored on an
48/// [`ObjectNode`]. Returned by [`ObjectAnims::bind`]; used with
49/// [`cancel`](ObjectAnims::cancel), [`pause_anim`](ObjectAnims::pause_anim),
50/// and [`resume_anim`](ObjectAnims::resume_anim).
51///
52/// Distinct from [`crate::anim::AnimId`], which keys the standalone
53/// [`Animations`](crate::anim::Animations) registry. The distinction is
54/// meaningful: `ObjectAnimId` is located by walking the tree; `AnimId` indexes
55/// the registry directly.
56///
57/// LPAR-06 §6.9 registration policy: **Specification Required**.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct ObjectAnimId(u32);
60
61// ---------------------------------------------------------------------------
62// Per-entry type
63// ---------------------------------------------------------------------------
64
65struct ObjectAnimEntry {
66    id: ObjectAnimId,
67    tween: Tween,
68    apply: Box<dyn FnMut(i32) -> Option<Rect>>,
69    delay_ticks: u32,
70    on_complete: Option<Box<dyn FnOnce()>>,
71    paused: bool,
72}
73
74// ---------------------------------------------------------------------------
75// NodeAnimSet — the additive slot stored on ObjectNode
76// ---------------------------------------------------------------------------
77
78/// Per-node container for active animation entries.
79///
80/// Stored on [`ObjectNode`] as `Option<Box<NodeAnimSet>>`, initialized lazily
81/// on first [`bind`](ObjectAnims::bind) call. Dropped when the node is
82/// dropped; entries are silently discarded (no `on_complete`) on node drop.
83pub struct NodeAnimSet {
84    entries: Vec<ObjectAnimEntry>,
85}
86
87impl NodeAnimSet {
88    pub(crate) fn new() -> Self {
89        Self {
90            entries: Vec::new(),
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// ObjectAnims walker + id allocator
97// ---------------------------------------------------------------------------
98
99/// Walker and id allocator for object-bound animations (LPAR-06 §6).
100///
101/// `ObjectAnims` is stateless except for its id counter. The animation entries
102/// themselves live on the target [`ObjectNode`] nodes. On each
103/// [`tick`](Self::tick), `ObjectAnims` depth-first walks the live tree,
104/// advances each entry, invokes apply callbacks, pushes dirty rects to the
105/// caller-supplied `sink`, and removes completed entries (firing their
106/// `on_complete` hooks).
107///
108/// # no_std + alloc
109///
110/// Requires `alloc`, consistent with ANIM-00 `Animations`.
111#[derive(Default)]
112pub struct ObjectAnims {
113    next_id: u32,
114}
115
116impl ObjectAnims {
117    /// Create a new `ObjectAnims` id allocator.
118    pub fn new() -> Self {
119        Self { next_id: 0 }
120    }
121
122    /// Bind a tween animation to `node`.
123    ///
124    /// The entry is stored in `node`'s animation slot (allocated lazily).
125    /// Returns an [`ObjectAnimId`] that can be passed to [`cancel`](Self::cancel),
126    /// [`pause_anim`](Self::pause_anim), or [`resume_anim`](Self::resume_anim).
127    ///
128    /// `delay_ticks`: ticks before the tween begins stepping. During the delay
129    /// the entry is registered (the id is valid for cancel) but `apply` is not
130    /// called and no dirty rects are pushed (LPAR-06 §6.2).
131    ///
132    /// `on_complete`: optional closure called exactly once when the tween
133    /// finishes naturally. Not called on cancel or on node detach/drop.
134    pub fn bind(
135        &mut self,
136        node: &mut ObjectNode,
137        tween: Tween,
138        apply: Box<dyn FnMut(i32) -> Option<Rect>>,
139        delay_ticks: u32,
140        on_complete: Option<Box<dyn FnOnce()>>,
141    ) -> ObjectAnimId {
142        let id = ObjectAnimId(self.next_id);
143        self.next_id = self.next_id.wrapping_add(1);
144
145        let slot = node
146            .anims
147            .get_or_insert_with(|| Box::new(NodeAnimSet::new()));
148        slot.entries.push(ObjectAnimEntry {
149            id,
150            tween,
151            apply,
152            delay_ticks,
153            on_complete,
154            paused: false,
155        });
156        id
157    }
158
159    /// Advance all live animation entries by one tick, starting from `root`.
160    ///
161    /// Walks the tree depth-first. For each node:
162    /// - Entries with `paused = true` are skipped.
163    /// - Entries in their delay window (`delay_ticks > 0`) decrement their
164    ///   delay counter and are otherwise skipped.
165    /// - Once the delay expires, the tween is stepped and `apply` is called
166    ///   with the new value. Any returned `Rect` is pushed to `sink`.
167    /// - Entries whose tween reports [`finished`](Tween::finished) after
168    ///   stepping are removed and their `on_complete` hook is called (if any).
169    ///
170    /// Hidden nodes are **not** skipped (LPAR-06 §6.5). Detached nodes are
171    /// unreachable from `root` and are therefore silently bypassed by
172    /// construction (LPAR-06 §6.4).
173    pub fn tick(&mut self, root: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
174        tick_node(root, sink);
175    }
176
177    /// Remove the entry identified by `id` without firing its `on_complete`.
178    ///
179    /// Walks the tree to locate the entry. Returns `true` if the id was
180    /// found and removed, `false` if not found (already completed or unknown).
181    /// Mirrors [`Animations::cancel`](crate::anim::Animations::cancel) semantics.
182    pub fn cancel(&mut self, root: &mut ObjectNode, id: ObjectAnimId) -> bool {
183        cancel_in_node(root, id)
184    }
185
186    /// Pause the entry identified by `id`.
187    ///
188    /// Returns `true` if found. A paused entry is not advanced by
189    /// [`tick`](Self::tick).
190    pub fn pause_anim(&mut self, root: &mut ObjectNode, id: ObjectAnimId) -> bool {
191        set_paused_in_node(root, id, true)
192    }
193
194    /// Resume the entry identified by `id`.
195    ///
196    /// Returns `true` if found.
197    pub fn resume_anim(&mut self, root: &mut ObjectNode, id: ObjectAnimId) -> bool {
198        set_paused_in_node(root, id, false)
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Tree-walk helpers (free functions to avoid borrow conflicts)
204// ---------------------------------------------------------------------------
205
206fn tick_node(node: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
207    // Advance this node's entries.
208    advance_node_entries(node, sink);
209
210    // Recurse into children (depth-first, in sibling order).
211    // We iterate by index to avoid borrowing issues.
212    let child_count = node.children_mut().len();
213    for i in 0..child_count {
214        tick_node(&mut node.children_mut()[i], sink);
215    }
216}
217
218fn advance_node_entries(node: &mut ObjectNode, sink: &mut dyn FnMut(Rect)) {
219    let Some(anim_set) = node.anims.as_mut() else {
220        return;
221    };
222
223    let mut completed_indices: Vec<usize> = Vec::new();
224
225    for (i, entry) in anim_set.entries.iter_mut().enumerate() {
226        if entry.paused {
227            continue;
228        }
229
230        // Delay window: just decrement, don't step or apply.
231        if entry.delay_ticks > 0 {
232            entry.delay_ticks -= 1;
233            continue;
234        }
235
236        // Step the tween and invoke apply.
237        let value = entry.tween.step();
238        if let Some(rect) = (entry.apply)(value) {
239            sink(rect);
240        }
241
242        // Check completion after stepping (step() advances elapsed, finished()
243        // reads that).
244        if entry.tween.finished() {
245            completed_indices.push(i);
246        }
247    }
248
249    // Remove completed entries in reverse order (preserve indices during drain).
250    for &i in completed_indices.iter().rev() {
251        let entry = anim_set.entries.remove(i);
252        if let Some(cb) = entry.on_complete {
253            cb();
254        }
255    }
256}
257
258fn cancel_in_node(node: &mut ObjectNode, id: ObjectAnimId) -> bool {
259    // Check this node first.
260    if let Some(anim_set) = node.anims.as_mut() {
261        let before = anim_set.entries.len();
262        // Remove without firing on_complete.
263        anim_set.entries.retain(|e| e.id != id);
264        if anim_set.entries.len() != before {
265            return true;
266        }
267    }
268
269    // Recurse into children.
270    let child_count = node.children_mut().len();
271    for i in 0..child_count {
272        if cancel_in_node(&mut node.children_mut()[i], id) {
273            return true;
274        }
275    }
276    false
277}
278
279fn set_paused_in_node(node: &mut ObjectNode, id: ObjectAnimId, paused: bool) -> bool {
280    if let Some(anim_set) = node.anims.as_mut() {
281        for entry in anim_set.entries.iter_mut() {
282            if entry.id == id {
283                entry.paused = paused;
284                return true;
285            }
286        }
287    }
288
289    let child_count = node.children_mut().len();
290    for i in 0..child_count {
291        if set_paused_in_node(&mut node.children_mut()[i], id, paused) {
292            return true;
293        }
294    }
295    false
296}
297
298// ---------------------------------------------------------------------------
299// Tests
300// ---------------------------------------------------------------------------
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use alloc::rc::Rc;
306    use alloc::vec;
307    use alloc::vec::Vec;
308    use core::cell::RefCell;
309
310    use crate::anim::{Easing, LoopMode, Tween};
311    use crate::event::Event;
312    use crate::object::{ObjectFlags, ObjectNode};
313    use crate::renderer::Renderer;
314    use crate::widget::{Rect, Widget};
315
316    // -----------------------------------------------------------------------
317    // Minimal test widget
318    // -----------------------------------------------------------------------
319
320    struct TinyWidget {
321        bounds: Rect,
322    }
323
324    impl TinyWidget {
325        fn node(x: i32, y: i32, w: i32, h: i32) -> ObjectNode {
326            ObjectNode::new(Rc::new(RefCell::new(Self {
327                bounds: Rect {
328                    x,
329                    y,
330                    width: w,
331                    height: h,
332                },
333            })))
334        }
335    }
336
337    impl Widget for TinyWidget {
338        fn bounds(&self) -> Rect {
339            self.bounds
340        }
341        fn draw(&self, _renderer: &mut dyn Renderer) {}
342        fn handle_event(&mut self, _event: &Event) -> bool {
343            false
344        }
345    }
346
347    fn collect_dirty(oa: &mut ObjectAnims, root: &mut ObjectNode) -> Vec<Rect> {
348        let mut rects = Vec::new();
349        oa.tick(root, &mut |r| rects.push(r));
350        rects
351    }
352
353    // -----------------------------------------------------------------------
354    // bind + tick advances tween and applies
355    // -----------------------------------------------------------------------
356
357    #[test]
358    fn bind_and_tick_advances_tween_applies_value() {
359        let mut root = TinyWidget::node(0, 0, 100, 100);
360        let mut oa = ObjectAnims::new();
361
362        let applied: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
363        let a = applied.clone();
364
365        let tween = Tween::new(0, 10, 2); // 0→10 over 2 ticks
366        oa.bind(
367            &mut root,
368            tween,
369            Box::new(move |v| {
370                a.borrow_mut().push(v);
371                None
372            }),
373            0,
374            None,
375        );
376
377        collect_dirty(&mut oa, &mut root); // tick 1: value = 5
378        collect_dirty(&mut oa, &mut root); // tick 2: value = 10, completed → removed
379        collect_dirty(&mut oa, &mut root); // tick 3: no entries
380
381        assert_eq!(
382            *applied.borrow(),
383            vec![5, 10],
384            "apply called with tween values"
385        );
386        assert!(
387            root.anims.as_ref().is_none_or(|s| s.entries.is_empty()),
388            "entry removed after completion"
389        );
390    }
391
392    // -----------------------------------------------------------------------
393    // Delay window: no apply/dirty until delay expires
394    // -----------------------------------------------------------------------
395
396    #[test]
397    fn delay_window_no_apply_until_expired() {
398        let mut root = TinyWidget::node(0, 0, 100, 100);
399        let mut oa = ObjectAnims::new();
400
401        let applied: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
402        let a = applied.clone();
403
404        let tween = Tween::new(0, 100, 3);
405        oa.bind(
406            &mut root,
407            tween,
408            Box::new(move |v| {
409                a.borrow_mut().push(v);
410                None
411            }),
412            2, // delay: 2 ticks
413            None,
414        );
415
416        // During delay: no apply.
417        collect_dirty(&mut oa, &mut root); // delay tick 1 (2→1)
418        collect_dirty(&mut oa, &mut root); // delay tick 2 (1→0)
419        assert!(applied.borrow().is_empty(), "no apply during delay");
420
421        // After delay: tween starts at elapsed=0.
422        let rects1 = collect_dirty(&mut oa, &mut root); // tween step 1
423        assert!(!applied.borrow().is_empty(), "apply fires after delay");
424        // First value: Tween::new(0,100,3).value_at(1) = 33 (linear)
425        assert_eq!(*applied.borrow(), vec![33], "first value at elapsed=1");
426        let _ = rects1;
427    }
428
429    // -----------------------------------------------------------------------
430    // Dirty rects reach the sink
431    // -----------------------------------------------------------------------
432
433    #[test]
434    fn dirty_rects_reach_sink() {
435        let mut root = TinyWidget::node(0, 0, 100, 100);
436        let mut oa = ObjectAnims::new();
437
438        let r = Rect {
439            x: 5,
440            y: 5,
441            width: 10,
442            height: 10,
443        };
444        let tween = Tween::new(0, 1, 10).with_loop(LoopMode::Repeat(0)); // infinite
445        oa.bind(&mut root, tween, Box::new(move |_v| Some(r)), 0, None);
446
447        let rects = collect_dirty(&mut oa, &mut root);
448        assert_eq!(rects, vec![r], "dirty rect pushed to sink");
449    }
450
451    // -----------------------------------------------------------------------
452    // Completion fires on_complete exactly once, not on cancel
453    // -----------------------------------------------------------------------
454
455    #[test]
456    fn completion_fires_on_complete_once() {
457        let mut root = TinyWidget::node(0, 0, 100, 100);
458        let mut oa = ObjectAnims::new();
459
460        let fired: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
461        let f = fired.clone();
462
463        let tween = Tween::new(0, 1, 1); // completes after 1 tick
464        oa.bind(
465            &mut root,
466            tween,
467            Box::new(|_v| None),
468            0,
469            Some(Box::new(move || *f.borrow_mut() += 1)),
470        );
471
472        collect_dirty(&mut oa, &mut root); // tick → completes → on_complete called
473        collect_dirty(&mut oa, &mut root); // no entry left
474        collect_dirty(&mut oa, &mut root);
475
476        assert_eq!(*fired.borrow(), 1, "on_complete fired exactly once");
477    }
478
479    #[test]
480    fn cancel_does_not_fire_on_complete() {
481        let mut root = TinyWidget::node(0, 0, 100, 100);
482        let mut oa = ObjectAnims::new();
483
484        let fired: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
485        let f = fired.clone();
486
487        let tween = Tween::new(0, 1, 100); // long animation
488        let id = oa.bind(
489            &mut root,
490            tween,
491            Box::new(|_v| None),
492            0,
493            Some(Box::new(move || *f.borrow_mut() += 1)),
494        );
495
496        collect_dirty(&mut oa, &mut root); // advance a bit
497
498        let found = oa.cancel(&mut root, id);
499        assert!(found, "cancel returned true for live id");
500
501        collect_dirty(&mut oa, &mut root); // no more entries
502
503        assert_eq!(*fired.borrow(), 0, "on_complete NOT called on cancel");
504    }
505
506    // -----------------------------------------------------------------------
507    // Detach-cancellation: bind on child, detach child, tick root
508    // -----------------------------------------------------------------------
509
510    #[test]
511    fn detach_cancellation_apply_not_called_after_detach() {
512        let mut root = TinyWidget::node(0, 0, 100, 100);
513        let child = TinyWidget::node(0, 0, 10, 10);
514        root.append_child(child);
515
516        let mut oa = ObjectAnims::new();
517
518        let apply_called: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
519        let complete_called: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
520        let ac = apply_called.clone();
521        let cc = complete_called.clone();
522
523        let tween = Tween::new(0, 10, 100); // long animation
524        oa.bind(
525            root.children_mut().first_mut().unwrap(),
526            tween,
527            Box::new(move |_v| {
528                *ac.borrow_mut() += 1;
529                None
530            }),
531            0,
532            Some(Box::new(move || *cc.borrow_mut() += 1)),
533        );
534
535        // Detach the child.
536        let _detached = root.detach_child(0).expect("child present");
537
538        // Tick root: detached child is unreachable → apply and on_complete not called.
539        collect_dirty(&mut oa, &mut root);
540        collect_dirty(&mut oa, &mut root);
541
542        assert_eq!(*apply_called.borrow(), 0, "apply not called after detach");
543        assert_eq!(
544            *complete_called.borrow(),
545            0,
546            "on_complete not fired after detach"
547        );
548    }
549
550    // -----------------------------------------------------------------------
551    // Hide does not cancel: hidden node's animation still advances
552    // -----------------------------------------------------------------------
553
554    #[test]
555    fn hide_does_not_cancel_animation() {
556        let mut root = TinyWidget::node(0, 0, 100, 100);
557        let mut oa = ObjectAnims::new();
558
559        let apply_count: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
560        let ac = apply_count.clone();
561
562        // Bind directly to root (simpler than adding a child).
563        let tween = Tween::new(0, 10, 10).with_loop(LoopMode::Repeat(0));
564        oa.bind(
565            &mut root,
566            tween,
567            Box::new(move |_v| {
568                *ac.borrow_mut() += 1;
569                None
570            }),
571            0,
572            None,
573        );
574
575        // Hide the root.
576        root.set_flag(ObjectFlags::HIDDEN, true);
577
578        // Tick several times: animation still advances on hidden node.
579        collect_dirty(&mut oa, &mut root);
580        collect_dirty(&mut oa, &mut root);
581        collect_dirty(&mut oa, &mut root);
582
583        assert_eq!(
584            *apply_count.borrow(),
585            3,
586            "animation advances even when node is hidden"
587        );
588    }
589
590    // -----------------------------------------------------------------------
591    // Five concurrent animations accumulate ≤ 5 dirty rects per tick
592    // -----------------------------------------------------------------------
593
594    #[test]
595    fn five_concurrent_anims_produce_at_most_five_dirty_rects() {
596        let mut root = TinyWidget::node(0, 0, 100, 100);
597        let mut oa = ObjectAnims::new();
598
599        for i in 0..5i32 {
600            let r = Rect {
601                x: i * 10,
602                y: 0,
603                width: 10,
604                height: 10,
605            };
606            let tween = Tween::new(0, 1, 50).with_loop(LoopMode::Repeat(0));
607            oa.bind(&mut root, tween, Box::new(move |_v| Some(r)), 0, None);
608        }
609
610        for _ in 0..20 {
611            let rects = collect_dirty(&mut oa, &mut root);
612            assert!(
613                rects.len() <= 5,
614                "at most 5 dirty rects per tick, got {}",
615                rects.len()
616            );
617        }
618    }
619
620    // -----------------------------------------------------------------------
621    // pause_anim / resume_anim
622    // -----------------------------------------------------------------------
623
624    #[test]
625    fn pause_and_resume_anim() {
626        let mut root = TinyWidget::node(0, 0, 100, 100);
627        let mut oa = ObjectAnims::new();
628
629        let values: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
630        let v = values.clone();
631
632        let tween = Tween::new(0, 10, 10).with_loop(LoopMode::Repeat(0));
633        let id = oa.bind(
634            &mut root,
635            tween,
636            Box::new(move |val| {
637                v.borrow_mut().push(val);
638                None
639            }),
640            0,
641            None,
642        );
643
644        collect_dirty(&mut oa, &mut root); // tick 1: value sampled
645        let count_after_first_tick = values.borrow().len();
646        assert_eq!(count_after_first_tick, 1);
647
648        // Pause.
649        assert!(oa.pause_anim(&mut root, id));
650        collect_dirty(&mut oa, &mut root); // paused: no advance
651        collect_dirty(&mut oa, &mut root);
652        assert_eq!(values.borrow().len(), 1, "no advance while paused");
653
654        // Resume.
655        assert!(oa.resume_anim(&mut root, id));
656        collect_dirty(&mut oa, &mut root); // resumes
657        assert_eq!(values.borrow().len(), 2, "resumes after un-pause");
658    }
659
660    // -----------------------------------------------------------------------
661    // Cancel unknown id returns false
662    // -----------------------------------------------------------------------
663
664    #[test]
665    fn cancel_unknown_id_returns_false() {
666        let mut root = TinyWidget::node(0, 0, 100, 100);
667        let mut oa = ObjectAnims::new();
668        let unknown = ObjectAnimId(999);
669        assert!(!oa.cancel(&mut root, unknown));
670    }
671
672    // -----------------------------------------------------------------------
673    // Deeply nested child receives tick
674    // -----------------------------------------------------------------------
675
676    #[test]
677    fn deeply_nested_child_animation_advances() {
678        let mut root = TinyWidget::node(0, 0, 100, 100);
679        let child = TinyWidget::node(0, 0, 50, 50);
680        root.append_child(child);
681        let grandchild = TinyWidget::node(0, 0, 20, 20);
682        root.children_mut()[0].append_child(grandchild);
683
684        let mut oa = ObjectAnims::new();
685        let fired: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
686        let f = fired.clone();
687
688        let tween = Tween::new(0, 1, 1).with_easing(Easing::Linear);
689        oa.bind(
690            &mut root.children_mut()[0].children_mut()[0],
691            tween,
692            Box::new(move |_v| {
693                *f.borrow_mut() += 1;
694                None
695            }),
696            0,
697            None,
698        );
699
700        collect_dirty(&mut oa, &mut root);
701        assert_eq!(
702            *fired.borrow(),
703            1,
704            "grandchild animation advanced by root tick"
705        );
706    }
707}