Skip to main content

teksilo_core/
animation.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Animation scheduler — drives `Signal<f32>` values smoothly over time.
5//!
6//! The scheduler stores active animations and advances them each frame.
7//! It uses simulated time (for deterministic tests via `advance_time`)
8//! or real time (for windowed apps).
9//!
10//! ## High-level API
11//!
12//! ```
13//! # use std::time::Duration;
14//! # use teksilo_tokens::Easing;
15//! # use teksilo_core::signal::Signal;
16//! // From an event handler or build():
17//! # let sidebar_width = Signal::new_animated(300.0_f32);
18//! sidebar_width.animate_to(0.0, Duration::from_millis(200), Easing::EaseInOut);
19//! ```
20//!
21//! This replaces the current value with a smooth interpolation to the target
22//! over the given duration. The framework drives the animation automatically.
23
24use std::time::{Duration, Instant};
25
26use teksilo_tokens::Easing;
27
28use crate::arena::WidgetArena;
29use crate::signal::Signal;
30use crate::widget_id::WidgetId;
31
32/// A pending animation request on a `Signal<f32>`.
33///
34/// Filled in by `Signal::animate_to()` / `Signal::animate_looping()` and
35/// consumed by the widget tree's `process_pending_animations` pass, which
36/// hands it to the scheduler.
37#[derive(Debug, Clone)]
38pub struct AnimationRequest {
39    pub target: f32,
40    pub duration: Duration,
41    pub easing: Easing,
42    pub frame_interval: Option<Duration>,
43    /// If true, the animation loops: resets to the signal's current
44    /// value each time it reaches `target`.
45    pub looping: bool,
46    /// Per-tick quantization: skip `signal.set(value)` when the new value
47    /// differs from the last set value by less than this. Terminal ticks
48    /// (completion / loop restart) always bypass the check. `0.0` = always
49    /// set (default).
50    pub epsilon: f32,
51    /// Opt-in wall-clock cap. When elapsed since the animation's start
52    /// exceeds this, the animation snaps to `start_value` and drops.
53    /// `None` = no cap.
54    pub max_duration: Option<Duration>,
55}
56
57impl Default for AnimationRequest {
58    fn default() -> Self {
59        Self {
60            target: 0.0,
61            duration: Duration::ZERO,
62            easing: Easing::Linear,
63            frame_interval: None,
64            looping: false,
65            epsilon: 0.0,
66            max_duration: None,
67        }
68    }
69}
70
71/// A single active animation driving a `Signal<f32>` from `start` to `end`.
72struct ActiveAnimation {
73    widget_id: WidgetId,
74    signal: Signal<f32>,
75    start_value: f32,
76    end_value: f32,
77    start_time: Instant,
78    duration: Duration,
79    easing: Easing,
80    frame_interval: Duration,
81    next_tick: Instant,
82    /// If true, the animation restarts from `start_value` when it
83    /// reaches `end_value`, looping indefinitely. Stopped by
84    /// `cancel()` / `cancel_by_widget()` or by widget rebuild/destroy.
85    looping: bool,
86    epsilon: f32,
87    last_set_value: f32,
88    /// When the animation entered the scheduler, on whichever clock the tree
89    /// measures animations against; used with `max_duration` to enforce the
90    /// opt-in cap. Distinct from `start_time` in that a pause/resume rebases
91    /// that and not this — but `rebase` moves both, or a cap would measure the
92    /// tree's whole age rather than the animation's own.
93    started_at: Instant,
94    max_duration: Option<Duration>,
95}
96
97/// Default frame interval for animations: 60 Hz (16.667 ms). Matches
98/// the most common display refresh rate, so a tween advances once per
99/// vsync on a 60 Hz panel and every other frame on 120 Hz. Individual
100/// animations can override via
101/// [`AnimationSpec::frame_interval`](crate::AnimationSpec::frame_interval),
102/// or `Signal::animate_to_with_frame_interval` on the raw signal API:
103/// slow or wide loops where the eye can't resolve sub-30-Hz detail
104/// (e.g. `ProgressBar::indeterminate` at 15 Hz) deliberately throttle
105/// to halve wgpu submits.
106const DEFAULT_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
107
108/// Manages active animations and advances them each frame.
109pub struct AnimationScheduler {
110    animations: Vec<ActiveAnimation>,
111    /// `false` pauses every **looping** entry: `tick` skips it and
112    /// `next_deadline` leaves it out, so a spinner in a window nobody is
113    /// looking at stops contributing to `ControlFlow::WaitUntil`. Used while
114    /// the owning window is unfocused or occluded. One-shot tweens are not
115    /// paused: see [`set_window_active`](Self::set_window_active).
116    window_active: bool,
117    /// When the scheduler last went inactive, on whichever clock the tree
118    /// measures animations against. Used on resume to rebase each looping
119    /// animation's `start_time` so `t` is phase-continuous across the pause
120    /// (no snap, no skipped frames). `rebase` moves this mark too: a pause
121    /// that spans a hand-back would otherwise resume the animation
122    /// *backwards*, the resume offset being the whole gap between the two
123    /// axes rather than the time spent paused.
124    paused_at: Option<Instant>,
125}
126
127impl AnimationScheduler {
128    pub fn new() -> Self {
129        Self {
130            animations: Vec::new(),
131            window_active: true,
132            paused_at: None,
133        }
134    }
135
136    /// Start animating a `Signal<f32>` from its current value to `target`.
137    /// If the signal is already being animated, the previous animation is
138    /// replaced (the current in-flight value becomes the new start).
139    pub fn animate(
140        &mut self,
141        signal: &Signal<f32>,
142        widget_id: WidgetId,
143        target: f32,
144        duration: Duration,
145        easing: Easing,
146        now: Instant,
147    ) {
148        self.animate_with_options(
149            signal, widget_id, target, duration, easing, None, 0.0, None, now,
150        );
151    }
152
153    #[allow(clippy::too_many_arguments)]
154    pub fn animate_with_options(
155        &mut self,
156        signal: &Signal<f32>,
157        widget_id: WidgetId,
158        target: f32,
159        duration: Duration,
160        easing: Easing,
161        frame_interval: Option<Duration>,
162        epsilon: f32,
163        max_duration: Option<Duration>,
164        now: Instant,
165    ) {
166        // If an animation is already in flight on this signal, fast-forward
167        // its value to where it *should* be at `now` before cancelling.
168        // Without this, a stream of `animate_to` calls (e.g. one per frame
169        // from a fast mouse-wheel flick) would each restart from the same
170        // pre-tick `signal.get()` value: `process_pending_animations` runs
171        // before `tick` in the layout pass, so a freshly-started animation
172        // always has `elapsed == 0` on its first tick. Net effect: the
173        // signal would never advance until events stop, then the last
174        // animation eases to target — the "lag-then-catch-up" pattern.
175        if let Some(existing) = self
176            .animations
177            .iter()
178            .find(|a| Signal::same(&a.signal, signal))
179            && !existing.looping
180        {
181            let elapsed = now.saturating_duration_since(existing.start_time);
182            let t = if existing.duration.is_zero() {
183                1.0
184            } else {
185                (elapsed.as_secs_f32() / existing.duration.as_secs_f32()).min(1.0)
186            };
187            let eased = existing.easing.apply(t);
188            let value = teksilo_tokens::lerp(existing.start_value, existing.end_value, eased);
189            signal.set(value);
190        }
191
192        let current = signal.get();
193        self.cancel(signal);
194
195        if (current - target).abs() < f32::EPSILON || duration.is_zero() {
196            signal.set(target);
197            signal.clear_animation_target();
198            return;
199        }
200
201        self.animations.push(ActiveAnimation {
202            widget_id,
203            signal: signal.clone(),
204            start_value: current,
205            end_value: target,
206            start_time: now,
207            duration,
208            easing,
209            frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
210            next_tick: now,
211            looping: false,
212            epsilon,
213            last_set_value: current,
214            started_at: now,
215            max_duration,
216        });
217    }
218
219    /// Start a looping animation that cycles from `start` to `end`
220    /// repeatedly. The signal resets to `start` each time it reaches
221    /// `end`. Runs until cancelled.
222    #[allow(clippy::too_many_arguments)]
223    pub fn animate_looping(
224        &mut self,
225        signal: &Signal<f32>,
226        widget_id: WidgetId,
227        start: f32,
228        end: f32,
229        period: Duration,
230        easing: Easing,
231        frame_interval: Option<Duration>,
232        epsilon: f32,
233        max_duration: Option<Duration>,
234        now: Instant,
235    ) {
236        self.cancel(signal);
237        signal.set(start);
238
239        self.animations.push(ActiveAnimation {
240            widget_id,
241            signal: signal.clone(),
242            start_value: start,
243            end_value: end,
244            start_time: now,
245            duration: period,
246            easing,
247            frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
248            next_tick: now,
249            looping: true,
250            epsilon,
251            last_set_value: start,
252            started_at: now,
253            max_duration,
254        });
255    }
256
257    /// Cancel any active animation on the given signal.
258    pub fn cancel(&mut self, signal: &Signal<f32>) {
259        self.animations.retain(|a| !Signal::same(&a.signal, signal));
260    }
261
262    /// Cancel every animation whose driving widget matches `widget_id`.
263    ///
264    /// Called when a widget is destroyed or rebuilt: the widget's
265    /// `Signal<f32>` clones in the scheduler would otherwise outlive the
266    /// widget, continuing to tick against an orphaned signal whose
267    /// observers no longer exist — silent CPU waste and, on rebuild, a
268    /// second animation for the fresh signal stacking on top of the old.
269    pub fn cancel_by_widget(&mut self, widget_id: WidgetId) {
270        self.animations.retain(|a| {
271            if a.widget_id == widget_id {
272                a.signal.clear_animation_target();
273                false
274            } else {
275                true
276            }
277        });
278    }
279
280    /// Mark the owning window as active (focused-and-visible) or not.
281    ///
282    /// Inactive: `tick` skips every **looping** animation and
283    /// `next_deadline` leaves them out. On transition back to active, each
284    /// looping animation's `start_time` is rebased by the time it spent
285    /// paused so the eased phase `t` is continuous: a 50%-through sweep
286    /// resumes at 50%, not snapped to some other spot on the curve.
287    ///
288    /// **One-shot tweens run on.** They are the same exception the
289    /// visibility gate in [`tick`](Self::tick) makes, for the same reason: a
290    /// tween is bounded by its own duration, so letting it finish costs a
291    /// handful of frames, while pausing it holds whatever it drives at an
292    /// arbitrary point. A `Collapse` opened while its window has no focus
293    /// (by an assistive technology, an automation client, or the app itself
294    /// in a window behind another) would otherwise keep its height at zero
295    /// until the window is focused again, with the disclosure already
296    /// reporting itself expanded.
297    pub fn set_window_active(&mut self, active: bool, now: Instant) {
298        if self.window_active == active {
299            return;
300        }
301        if active {
302            if let Some(paused_at) = self.paused_at.take() {
303                for anim in self.animations.iter_mut().filter(|anim| anim.looping) {
304                    // Paused since the window went inactive, or since the
305                    // loop started if that came later: a loop started in an
306                    // inactive window has not run at all, and shifting it by
307                    // the whole pause would hold it back by the time the
308                    // window was inactive before it existed.
309                    let offset = now.saturating_duration_since(paused_at.max(anim.start_time));
310                    anim.start_time += offset;
311                    anim.next_tick = now;
312                }
313            }
314        } else {
315            self.paused_at = Some(now);
316        }
317        self.window_active = active;
318    }
319
320    pub fn is_window_active(&self) -> bool {
321        self.window_active
322    }
323
324    /// Move every stored instant from one time axis onto another, preserving
325    /// each animation's elapsed time across the move.
326    ///
327    /// Called when the tree switches the clock it measures animations against
328    /// — between the wall clock and its simulated one, in either direction.
329    /// Every instant this scheduler holds (`start_time`, `next_tick`,
330    /// `started_at`, and the pause mark) was taken on the axis that reads
331    /// `from` at this moment and has to be re-expressed on the one that reads
332    /// `to`, or the very next tick measures an elapsed time that includes the
333    /// whole gap between the two axes: forwards it completes every animation
334    /// at once, backwards it clamps every elapsed time to zero and nothing
335    /// moves again.
336    ///
337    /// Shifting backwards past the underlying clock's own origin is not
338    /// representable; such an instant is clamped to `to`, which costs that
339    /// animation its accumulated phase and nothing else. Reaching that clamp
340    /// takes a stored instant older than the platform's monotonic origin, so it
341    /// is unreachable in practice and deliberately left untested.
342    pub fn rebase(&mut self, from: Instant, to: Instant) {
343        if to == from {
344            return;
345        }
346        let shift = |instant: Instant| -> Instant {
347            if to >= from {
348                instant + (to - from)
349            } else {
350                instant.checked_sub(from - to).unwrap_or(to)
351            }
352        };
353        for anim in &mut self.animations {
354            anim.start_time = shift(anim.start_time);
355            anim.next_tick = shift(anim.next_tick);
356            anim.started_at = shift(anim.started_at);
357        }
358        if let Some(paused_at) = self.paused_at {
359            self.paused_at = Some(shift(paused_at));
360        }
361    }
362
363    /// Advance all active animations to the given time.
364    /// Returns true if any animation is still running *and eligible to
365    /// run next tick* (caller should request another frame).
366    ///
367    /// `arena` + `paint_epoch` gate per-widget visibility for **looping**
368    /// animations only: a continuous spinner whose owner widget is
369    /// dormant or hasn't been painted in the most recent paint pass
370    /// skips its tick. One-shot tweens are NOT visibility-gated — a
371    /// widget that animates its own size from zero (e.g. `Collapse`
372    /// growing from height=0 to natural) would otherwise be paused on
373    /// the very tick that would make it visible, locking it in the
374    /// invisible state forever. Pass `paint_epoch == 0` to disable the
375    /// gate entirely (headless tests that never call `render()`).
376    ///
377    /// The window-inactive pause is gated the same way: it holds looping
378    /// animations only (see [`set_window_active`](Self::set_window_active)).
379    pub fn tick(&mut self, now: Instant, arena: &WidgetArena, paint_epoch: u64) -> bool {
380        let window_active = self.window_active;
381        self.animations.retain_mut(|anim| {
382            if !anim_widget_alive(arena, anim.widget_id) {
383                anim.signal.clear_animation_target();
384                return false;
385            }
386
387            if let Some(max) = anim.max_duration
388                && now.saturating_duration_since(anim.started_at) >= max
389            {
390                anim.signal.set(anim.start_value);
391                anim.signal.clear_animation_target();
392                return false;
393            }
394
395            if anim.looping && !window_active {
396                // Paused with its window. `set_window_active` rebases the
397                // start on resume, and `next_deadline` leaves the entry out
398                // meanwhile, so nothing wakes the loop for it.
399                return true;
400            }
401
402            if anim.looping && !anim_widget_visible(arena, anim.widget_id, paint_epoch) {
403                // Looping animation on an offscreen owner — pause to
404                // save CPU. Leave start_time alone so resume picks up
405                // mid-phase. Push next_tick so we don't spin on a
406                // paused entry when the scheduler is polled via some
407                // other deadline.
408                anim.next_tick = now + anim.frame_interval;
409                return true;
410            }
411
412            if now < anim.next_tick {
413                return true;
414            }
415
416            let elapsed = now.saturating_duration_since(anim.start_time);
417            let t = if anim.duration.is_zero() {
418                1.0
419            } else {
420                (elapsed.as_secs_f32() / anim.duration.as_secs_f32()).min(1.0)
421            };
422            let eased = anim.easing.apply(t);
423            let value = teksilo_tokens::lerp(anim.start_value, anim.end_value, eased);
424
425            let terminal = t >= 1.0;
426            // Terminal ticks always set unconditionally so we land exactly
427            // on end_value (or snap to start on loop restart); epsilon
428            // quantization only applies to intermediate ticks.
429            if terminal || (value - anim.last_set_value).abs() >= anim.epsilon {
430                anim.signal.set(value);
431                anim.last_set_value = value;
432            }
433
434            if t >= 1.0 && anim.looping {
435                anim.start_time = now;
436                anim.signal.set(anim.start_value);
437                anim.last_set_value = anim.start_value;
438                anim.next_tick = now + anim.frame_interval;
439                true
440            } else if t >= 1.0 {
441                anim.signal.clear_animation_target();
442                false
443            } else {
444                anim.next_tick = now + anim.frame_interval;
445                true
446            }
447        });
448
449        self.has_running()
450    }
451
452    /// Whether any animation is currently stored in the scheduler
453    /// (ignores pause state — prefer `has_running`).
454    pub fn has_active(&self) -> bool {
455        !self.animations.is_empty()
456    }
457
458    /// Whether any animation is *eligible to advance* on the next tick:
459    /// any one-shot tween, or any looping animation while the window is
460    /// active. Used by the idle-work predicates so a window-paused
461    /// scheduler doesn't keep the event loop in `ControlFlow::WaitUntil`
462    /// for its loops, while a tween started in that window still finishes.
463    ///
464    /// This does NOT check per-widget visibility (we'd need the arena
465    /// and the current paint epoch). An animation whose widget is
466    /// offscreen is still reported here; the per-widget gate lives in
467    /// `next_deadline` and `tick` directly.
468    pub fn has_running(&self) -> bool {
469        self.animations
470            .iter()
471            .any(|anim| self.window_active || !anim.looping)
472    }
473
474    /// Earliest deadline at which a not-paused animation wants to
475    /// tick. Returns `None` when there are no animations at all, or when
476    /// every one left is a looping animation that is window-paused or
477    /// hidden. One-shot tweens are gated by neither: see the matching
478    /// notes on [`tick`](Self::tick) and
479    /// [`set_window_active`](Self::set_window_active).
480    pub fn next_deadline(&self, arena: &WidgetArena, paint_epoch: u64) -> Option<Instant> {
481        self.animations
482            .iter()
483            .filter(|anim| {
484                anim_widget_alive(arena, anim.widget_id)
485                    && (!anim.looping
486                        || (self.window_active
487                            && anim_widget_visible(arena, anim.widget_id, paint_epoch)))
488            })
489            .map(|anim| anim.next_tick)
490            .min()
491    }
492
493    /// Number of active animations (for testing/debugging).
494    pub fn active_count(&self) -> usize {
495        self.animations.len()
496    }
497}
498
499use crate::motion_visibility::{
500    alive as anim_widget_alive, painted_recently as anim_widget_visible,
501};
502
503impl Default for AnimationScheduler {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509impl std::fmt::Debug for AnimationScheduler {
510    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511        f.debug_struct("AnimationScheduler")
512            .field("active_count", &self.animations.len())
513            .field("window_active", &self.window_active)
514            .finish()
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::arena::WidgetArena;
522    use crate::test_widgets::FillWidget;
523
524    fn test_arena_with_widget() -> (WidgetArena, WidgetId) {
525        let mut arena = WidgetArena::new();
526        let id = arena.insert(Box::new(FillWidget::new()));
527        (arena, id)
528    }
529
530    #[test]
531    fn animate_from_current_to_target() {
532        let signal = Signal::<f32>::new_animated(0.0);
533        let mut scheduler = AnimationScheduler::new();
534        let (arena, id) = test_arena_with_widget();
535        let start = Instant::now();
536
537        scheduler.animate(
538            &signal,
539            id,
540            100.0,
541            Duration::from_millis(200),
542            Easing::Linear,
543            start,
544        );
545        assert_eq!(scheduler.active_count(), 1);
546
547        scheduler.tick(start, &arena, 0);
548        assert!((signal.get() - 0.0).abs() < 1.0);
549
550        let has_more = scheduler.tick(start + Duration::from_millis(100), &arena, 0);
551        assert!(has_more);
552        assert!((signal.get() - 50.0).abs() < 1.0);
553
554        let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
555        assert!(!has_more);
556        assert!((signal.get() - 100.0).abs() < 0.01);
557        assert_eq!(scheduler.active_count(), 0);
558    }
559
560    #[test]
561    fn eased_animation() {
562        let signal = Signal::<f32>::new_animated(0.0);
563        let mut scheduler = AnimationScheduler::new();
564        let (arena, id) = test_arena_with_widget();
565        let start = Instant::now();
566
567        scheduler.animate(
568            &signal,
569            id,
570            100.0,
571            Duration::from_millis(200),
572            Easing::EaseIn,
573            start,
574        );
575
576        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
577        assert!((signal.get() - 25.0).abs() < 1.0);
578    }
579
580    #[test]
581    fn zero_duration_sets_immediately() {
582        let signal = Signal::<f32>::new_animated(0.0);
583        let mut scheduler = AnimationScheduler::new();
584        let (_arena, id) = test_arena_with_widget();
585        let start = Instant::now();
586
587        scheduler.animate(&signal, id, 100.0, Duration::ZERO, Easing::Linear, start);
588        assert_eq!(scheduler.active_count(), 0);
589        assert!((signal.get() - 100.0).abs() < 0.01);
590    }
591
592    #[test]
593    fn replace_existing_animation() {
594        let signal = Signal::<f32>::new_animated(0.0);
595        let mut scheduler = AnimationScheduler::new();
596        let (arena, id) = test_arena_with_widget();
597        let start = Instant::now();
598
599        scheduler.animate(
600            &signal,
601            id,
602            100.0,
603            Duration::from_millis(200),
604            Easing::Linear,
605            start,
606        );
607
608        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
609        let mid_value = signal.get();
610        assert!((mid_value - 50.0).abs() < 1.0);
611
612        let mid_time = start + Duration::from_millis(100);
613        scheduler.animate(
614            &signal,
615            id,
616            0.0,
617            Duration::from_millis(100),
618            Easing::Linear,
619            mid_time,
620        );
621        assert_eq!(scheduler.active_count(), 1);
622
623        scheduler.tick(mid_time + Duration::from_millis(50), &arena, 0);
624        assert!((signal.get() - 25.0).abs() < 2.0);
625    }
626
627    #[test]
628    fn cancel_stops_animation() {
629        let signal = Signal::<f32>::new_animated(0.0);
630        let mut scheduler = AnimationScheduler::new();
631        let (_arena, id) = test_arena_with_widget();
632        let start = Instant::now();
633
634        scheduler.animate(
635            &signal,
636            id,
637            100.0,
638            Duration::from_millis(200),
639            Easing::Linear,
640            start,
641        );
642        assert_eq!(scheduler.active_count(), 1);
643
644        scheduler.cancel(&signal);
645        assert_eq!(scheduler.active_count(), 0);
646    }
647
648    #[test]
649    fn already_at_target_no_animation() {
650        let signal = Signal::<f32>::new_animated(50.0);
651        let mut scheduler = AnimationScheduler::new();
652        let (_arena, id) = test_arena_with_widget();
653        let start = Instant::now();
654
655        scheduler.animate(
656            &signal,
657            id,
658            50.0,
659            Duration::from_millis(200),
660            Easing::Linear,
661            start,
662        );
663        assert_eq!(scheduler.active_count(), 0);
664    }
665
666    #[test]
667    fn multiple_signals_animated_independently() {
668        let a = Signal::<f32>::new_animated(0.0);
669        let b = Signal::<f32>::new_animated(100.0);
670        let mut scheduler = AnimationScheduler::new();
671        let (arena, id) = test_arena_with_widget();
672        let start = Instant::now();
673
674        scheduler.animate(
675            &a,
676            id,
677            100.0,
678            Duration::from_millis(200),
679            Easing::Linear,
680            start,
681        );
682        scheduler.animate(
683            &b,
684            id,
685            0.0,
686            Duration::from_millis(200),
687            Easing::Linear,
688            start,
689        );
690        assert_eq!(scheduler.active_count(), 2);
691
692        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
693        assert!((a.get() - 50.0).abs() < 1.0);
694        assert!((b.get() - 50.0).abs() < 1.0);
695
696        scheduler.tick(start + Duration::from_millis(200), &arena, 0);
697        assert_eq!(scheduler.active_count(), 0);
698    }
699
700    #[test]
701    fn looping_animation_restarts() {
702        let signal = Signal::<f32>::new_animated(0.0);
703        let mut scheduler = AnimationScheduler::new();
704        let (arena, id) = test_arena_with_widget();
705        let start = Instant::now();
706
707        scheduler.animate_looping(
708            &signal,
709            id,
710            0.0,
711            100.0,
712            Duration::from_millis(200),
713            Easing::Linear,
714            None,
715            0.0,
716            None,
717            start,
718        );
719
720        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
721        assert!((signal.get() - 50.0).abs() < 1.0);
722
723        let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
724        assert!(has_more, "looping animation should keep running");
725        assert!(signal.get() < 5.0);
726
727        scheduler.tick(start + Duration::from_millis(300), &arena, 0);
728        assert!((signal.get() - 50.0).abs() < 5.0);
729    }
730
731    #[test]
732    fn looping_animation_cancelled() {
733        let signal = Signal::<f32>::new_animated(0.0);
734        let mut scheduler = AnimationScheduler::new();
735        let (_arena, id) = test_arena_with_widget();
736        let start = Instant::now();
737
738        scheduler.animate_looping(
739            &signal,
740            id,
741            0.0,
742            10.0,
743            Duration::from_millis(100),
744            Easing::Linear,
745            None,
746            0.0,
747            None,
748            start,
749        );
750        assert_eq!(scheduler.active_count(), 1);
751
752        scheduler.cancel(&signal);
753        assert_eq!(scheduler.active_count(), 0);
754    }
755
756    #[test]
757    fn cancel_by_widget_removes_all_animations_owned_by_widget() {
758        let a = Signal::<f32>::new_animated(0.0);
759        let b = Signal::<f32>::new_animated(0.0);
760        let c = Signal::<f32>::new_animated(0.0);
761        let mut scheduler = AnimationScheduler::new();
762        let mut arena = WidgetArena::new();
763        let id_x = arena.insert(Box::new(FillWidget::new()));
764        let id_y = arena.insert(Box::new(FillWidget::new()));
765        let now = Instant::now();
766
767        scheduler.animate(&a, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
768        scheduler.animate(&b, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
769        scheduler.animate(&c, id_y, 1.0, Duration::from_secs(1), Easing::Linear, now);
770        assert_eq!(scheduler.active_count(), 3);
771
772        scheduler.cancel_by_widget(id_x);
773        assert_eq!(scheduler.active_count(), 1);
774
775        // c (owned by id_y) still running
776        let _ = arena;
777    }
778
779    /// Start a 200 ms linear loop from 0 to 100 on `signal`, at `start`.
780    fn start_loop(
781        scheduler: &mut AnimationScheduler,
782        signal: &Signal<f32>,
783        id: WidgetId,
784        start: Instant,
785    ) {
786        scheduler.animate_looping(
787            signal,
788            id,
789            0.0,
790            100.0,
791            Duration::from_millis(200),
792            Easing::Linear,
793            None,
794            0.0,
795            None,
796            start,
797        );
798    }
799
800    #[test]
801    fn window_inactive_pauses_a_looping_tick() {
802        let signal = Signal::<f32>::new_animated(0.0);
803        let mut scheduler = AnimationScheduler::new();
804        let (arena, id) = test_arena_with_widget();
805        let start = Instant::now();
806
807        start_loop(&mut scheduler, &signal, id, start);
808        scheduler.set_window_active(false, start);
809
810        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
811        assert!(
812            signal.get() < 1.0,
813            "paused scheduler must not advance a loop"
814        );
815        assert!(scheduler.next_deadline(&arena, 0).is_none());
816        assert!(
817            !scheduler.has_running(),
818            "a paused loop must not keep the event loop awake"
819        );
820    }
821
822    /// A one-shot tween is not paused with its window: it finishes on time.
823    ///
824    /// Pausing it held whatever it drives at its start value, which for a
825    /// `Collapse` is a body of no height: an accordion opened in a window
826    /// without focus showed nothing until the window was focused.
827    #[test]
828    fn a_one_shot_tween_runs_on_in_an_inactive_window() {
829        let signal = Signal::<f32>::new_animated(0.0);
830        let mut scheduler = AnimationScheduler::new();
831        let (arena, id) = test_arena_with_widget();
832        let start = Instant::now();
833
834        scheduler.set_window_active(false, start);
835        scheduler.animate(
836            &signal,
837            id,
838            100.0,
839            Duration::from_millis(200),
840            Easing::Linear,
841            start,
842        );
843        assert!(
844            scheduler.has_running(),
845            "the tween must keep the event loop awake"
846        );
847        assert_eq!(
848            scheduler.next_deadline(&arena, 0),
849            Some(start),
850            "the tween must ask for its first frame"
851        );
852
853        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
854        assert!(
855            (signal.get() - 50.0).abs() < 1.0,
856            "half way after 100 ms of 200, got {}",
857            signal.get()
858        );
859        let more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
860        assert!((signal.get() - 100.0).abs() < 0.01, "lands on its target");
861        assert!(!more, "and leaves the scheduler");
862    }
863
864    /// A tween in flight when its window loses focus is neither held nor
865    /// rewound when the window gets it back.
866    #[test]
867    fn a_one_shot_tween_keeps_its_phase_across_a_focus_change() {
868        let signal = Signal::<f32>::new_animated(0.0);
869        let mut scheduler = AnimationScheduler::new();
870        let (arena, id) = test_arena_with_widget();
871        let start = Instant::now();
872
873        scheduler.animate(
874            &signal,
875            id,
876            100.0,
877            Duration::from_millis(400),
878            Easing::Linear,
879            start,
880        );
881        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
882        scheduler.set_window_active(false, start + Duration::from_millis(100));
883        scheduler.tick(start + Duration::from_millis(200), &arena, 0);
884        assert!(
885            (signal.get() - 50.0).abs() < 1.0,
886            "still moving without focus, got {}",
887            signal.get()
888        );
889        scheduler.set_window_active(true, start + Duration::from_millis(250));
890        scheduler.tick(start + Duration::from_millis(300), &arena, 0);
891        assert!(
892            (signal.get() - 75.0).abs() < 1.0,
893            "on the same schedule once focused again, got {}",
894            signal.get()
895        );
896    }
897
898    /// A loop started while its window has no focus starts when the window
899    /// gets it, not later by however long the window had been inactive
900    /// before the loop existed.
901    #[test]
902    fn a_loop_started_in_an_inactive_window_starts_on_resume() {
903        let signal = Signal::<f32>::new_animated(0.0);
904        let mut scheduler = AnimationScheduler::new();
905        let (arena, id) = test_arena_with_widget();
906        let start = Instant::now();
907
908        scheduler.set_window_active(false, start);
909        start_loop(&mut scheduler, &signal, id, start + Duration::from_secs(5));
910        let resume_at = start + Duration::from_secs(10);
911        scheduler.set_window_active(true, resume_at);
912        scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
913        assert!(
914            (signal.get() - 25.0).abs() < 1.0,
915            "a quarter through 50 ms after the window got focus, got {}",
916            signal.get()
917        );
918    }
919
920    #[test]
921    fn resume_rebases_phase_continuously() {
922        let signal = Signal::<f32>::new_animated(0.0);
923        let mut scheduler = AnimationScheduler::new();
924        let (arena, id) = test_arena_with_widget();
925        let start = Instant::now();
926
927        start_loop(&mut scheduler, &signal, id, start);
928
929        // Advance halfway (t=0.5 → value≈50).
930        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
931        assert!((signal.get() - 50.0).abs() < 1.0);
932
933        // Window goes inactive at t=100ms.
934        scheduler.set_window_active(false, start + Duration::from_millis(100));
935
936        // 10 seconds of real time pass with window hidden. No ticks happen.
937        let resume_at = start + Duration::from_millis(100) + Duration::from_secs(10);
938        scheduler.set_window_active(true, resume_at);
939
940        // 50ms *after* resume, we should be at ≈75% of the eased curve,
941        // NOT at 100% (which is what we'd get without rebasing start_time).
942        scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
943        let after_resume = signal.get();
944        assert!(
945            (after_resume - 75.0).abs() < 2.0,
946            "expected phase-continuous resume ≈ 75, got {after_resume}"
947        );
948    }
949
950    #[test]
951    fn epsilon_skips_intermediate_sets_but_not_terminal() {
952        let signal = Signal::<f32>::new_animated(0.0);
953        let mut scheduler = AnimationScheduler::new();
954        let (arena, id) = test_arena_with_widget();
955        let start = Instant::now();
956
957        // ε = 10 → tick at t=0.05 produces value=5, below ε, should skip.
958        scheduler.animate_with_options(
959            &signal,
960            id,
961            100.0,
962            Duration::from_millis(200),
963            Easing::Linear,
964            None,
965            10.0,
966            None,
967            start,
968        );
969
970        scheduler.tick(start + Duration::from_millis(10), &arena, 0);
971        assert!(
972            signal.get() < 1.0,
973            "sub-ε tick should NOT call signal.set, signal.get() = {}",
974            signal.get()
975        );
976
977        // Terminal tick must set end_value regardless of ε.
978        scheduler.tick(start + Duration::from_millis(200), &arena, 0);
979        assert!(
980            (signal.get() - 100.0).abs() < 0.01,
981            "terminal tick must bypass ε and land exactly on end"
982        );
983    }
984
985    #[test]
986    fn max_duration_snaps_to_start_and_drops() {
987        let signal = Signal::<f32>::new_animated(0.0);
988        let mut scheduler = AnimationScheduler::new();
989        let (arena, id) = test_arena_with_widget();
990        let start = Instant::now();
991
992        scheduler.animate_looping(
993            &signal,
994            id,
995            0.0,
996            100.0,
997            Duration::from_millis(200),
998            Easing::Linear,
999            None,
1000            0.0,
1001            Some(Duration::from_secs(1)),
1002            start,
1003        );
1004
1005        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
1006        assert!(signal.get() > 0.0);
1007
1008        // 1.5s past start-at: past the 1s cap.
1009        let has_more = scheduler.tick(start + Duration::from_millis(1500), &arena, 0);
1010        assert!(!has_more, "capped animation should drop");
1011        assert_eq!(scheduler.active_count(), 0);
1012        assert!(
1013            signal.get().abs() < 0.01,
1014            "capped animation should snap to start_value (0), got {}",
1015            signal.get()
1016        );
1017    }
1018
1019    #[test]
1020    fn one_shot_tween_runs_even_when_owner_appears_offscreen() {
1021        // Regression: a `Collapse`-style widget animating its own
1022        // height from 0 → natural would set up a one-shot tween whose
1023        // owner is the Collapse widget itself. Because the widget's
1024        // current bounds are zero, the paint pass would skip it,
1025        // never stamping `last_painted_epoch`. The visibility gate
1026        // would then see `lpe + 1 < paint_epoch` and pause the
1027        // animation forever — locking the widget at height=0.
1028        let signal = Signal::<f32>::new_animated(0.0);
1029        let mut scheduler = AnimationScheduler::new();
1030        let (mut arena, id) = test_arena_with_widget();
1031        // Simulate a never-painted widget (lpe stays at the default 0)
1032        // while the global paint_epoch has advanced many frames.
1033        let _ = arena.get_mut(id); // ensure node exists; lpe defaults to 0
1034        let start = Instant::now();
1035        let paint_epoch: u64 = 42; // many frames have passed
1036
1037        scheduler.animate(
1038            &signal,
1039            id,
1040            100.0,
1041            Duration::from_millis(200),
1042            Easing::Linear,
1043            start,
1044        );
1045
1046        scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
1047        assert!(
1048            signal.get() > 10.0,
1049            "one-shot tween must progress despite owner having stale paint_epoch (got {})",
1050            signal.get()
1051        );
1052
1053        scheduler.tick(start + Duration::from_millis(200), &arena, paint_epoch);
1054        assert!(
1055            (signal.get() - 100.0).abs() < 0.01,
1056            "one-shot tween must reach target (got {})",
1057            signal.get()
1058        );
1059    }
1060
1061    #[test]
1062    fn looping_animation_pauses_when_owner_offscreen() {
1063        // The looping case (e.g. spinner in a hidden tab) keeps the
1064        // visibility gate: we don't want a hidden spinner to burn CPU
1065        // ticking against a widget the user can't see.
1066        let signal = Signal::<f32>::new_animated(0.0);
1067        let mut scheduler = AnimationScheduler::new();
1068        let (arena, id) = test_arena_with_widget();
1069        let start = Instant::now();
1070        let paint_epoch: u64 = 42;
1071
1072        scheduler.animate_looping(
1073            &signal,
1074            id,
1075            0.0,
1076            100.0,
1077            Duration::from_millis(200),
1078            Easing::Linear,
1079            None,
1080            0.0,
1081            None,
1082            start,
1083        );
1084
1085        // With paint_epoch high and lpe at 0, the loop must NOT
1086        // progress — the value stays at the start.
1087        scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
1088        assert!(
1089            signal.get().abs() < 0.01,
1090            "looping animation should pause for offscreen owner (got {})",
1091            signal.get()
1092        );
1093    }
1094}