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}