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 entry: `tick` is a no-op and `next_deadline`
112    /// returns `None`, so the scheduler stops contributing to
113    /// `ControlFlow::WaitUntil`. Used to suspend animations while the
114    /// owning window is unfocused or occluded.
115    window_active: bool,
116    /// When the scheduler last went inactive, on whichever clock the tree
117    /// measures animations against. Used on resume to rebase each animation's
118    /// `start_time` so `t` is phase-continuous across the pause (no snap, no
119    /// skipped frames). `rebase` moves this mark too: a pause that spans a
120    /// hand-back would otherwise resume the animation *backwards*, the resume
121    /// offset being the whole gap between the two axes rather than the time
122    /// spent paused.
123    paused_at: Option<Instant>,
124}
125
126impl AnimationScheduler {
127    pub fn new() -> Self {
128        Self {
129            animations: Vec::new(),
130            window_active: true,
131            paused_at: None,
132        }
133    }
134
135    /// Start animating a `Signal<f32>` from its current value to `target`.
136    /// If the signal is already being animated, the previous animation is
137    /// replaced (the current in-flight value becomes the new start).
138    pub fn animate(
139        &mut self,
140        signal: &Signal<f32>,
141        widget_id: WidgetId,
142        target: f32,
143        duration: Duration,
144        easing: Easing,
145        now: Instant,
146    ) {
147        self.animate_with_options(
148            signal, widget_id, target, duration, easing, None, 0.0, None, now,
149        );
150    }
151
152    #[allow(clippy::too_many_arguments)]
153    pub fn animate_with_options(
154        &mut self,
155        signal: &Signal<f32>,
156        widget_id: WidgetId,
157        target: f32,
158        duration: Duration,
159        easing: Easing,
160        frame_interval: Option<Duration>,
161        epsilon: f32,
162        max_duration: Option<Duration>,
163        now: Instant,
164    ) {
165        // If an animation is already in flight on this signal, fast-forward
166        // its value to where it *should* be at `now` before cancelling.
167        // Without this, a stream of `animate_to` calls (e.g. one per frame
168        // from a fast mouse-wheel flick) would each restart from the same
169        // pre-tick `signal.get()` value: `process_pending_animations` runs
170        // before `tick` in the layout pass, so a freshly-started animation
171        // always has `elapsed == 0` on its first tick. Net effect: the
172        // signal would never advance until events stop, then the last
173        // animation eases to target — the "lag-then-catch-up" pattern.
174        if let Some(existing) = self
175            .animations
176            .iter()
177            .find(|a| Signal::same(&a.signal, signal))
178            && !existing.looping
179        {
180            let elapsed = now.saturating_duration_since(existing.start_time);
181            let t = if existing.duration.is_zero() {
182                1.0
183            } else {
184                (elapsed.as_secs_f32() / existing.duration.as_secs_f32()).min(1.0)
185            };
186            let eased = existing.easing.apply(t);
187            let value = teksilo_tokens::lerp(existing.start_value, existing.end_value, eased);
188            signal.set(value);
189        }
190
191        let current = signal.get();
192        self.cancel(signal);
193
194        if (current - target).abs() < f32::EPSILON || duration.is_zero() {
195            signal.set(target);
196            signal.clear_animation_target();
197            return;
198        }
199
200        self.animations.push(ActiveAnimation {
201            widget_id,
202            signal: signal.clone(),
203            start_value: current,
204            end_value: target,
205            start_time: now,
206            duration,
207            easing,
208            frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
209            next_tick: now,
210            looping: false,
211            epsilon,
212            last_set_value: current,
213            started_at: now,
214            max_duration,
215        });
216    }
217
218    /// Start a looping animation that cycles from `start` to `end`
219    /// repeatedly. The signal resets to `start` each time it reaches
220    /// `end`. Runs until cancelled.
221    #[allow(clippy::too_many_arguments)]
222    pub fn animate_looping(
223        &mut self,
224        signal: &Signal<f32>,
225        widget_id: WidgetId,
226        start: f32,
227        end: f32,
228        period: Duration,
229        easing: Easing,
230        frame_interval: Option<Duration>,
231        epsilon: f32,
232        max_duration: Option<Duration>,
233        now: Instant,
234    ) {
235        self.cancel(signal);
236        signal.set(start);
237
238        self.animations.push(ActiveAnimation {
239            widget_id,
240            signal: signal.clone(),
241            start_value: start,
242            end_value: end,
243            start_time: now,
244            duration: period,
245            easing,
246            frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
247            next_tick: now,
248            looping: true,
249            epsilon,
250            last_set_value: start,
251            started_at: now,
252            max_duration,
253        });
254    }
255
256    /// Cancel any active animation on the given signal.
257    pub fn cancel(&mut self, signal: &Signal<f32>) {
258        self.animations.retain(|a| !Signal::same(&a.signal, signal));
259    }
260
261    /// Cancel every animation whose driving widget matches `widget_id`.
262    ///
263    /// Called when a widget is destroyed or rebuilt: the widget's
264    /// `Signal<f32>` clones in the scheduler would otherwise outlive the
265    /// widget, continuing to tick against an orphaned signal whose
266    /// observers no longer exist — silent CPU waste and, on rebuild, a
267    /// second animation for the fresh signal stacking on top of the old.
268    pub fn cancel_by_widget(&mut self, widget_id: WidgetId) {
269        self.animations.retain(|a| {
270            if a.widget_id == widget_id {
271                a.signal.clear_animation_target();
272                false
273            } else {
274                true
275            }
276        });
277    }
278
279    /// Mark the owning window as active (focused-and-visible) or not.
280    ///
281    /// Inactive: `tick` is a no-op; `next_deadline` returns `None`. On
282    /// transition back to active, each animation's `start_time` is
283    /// rebased by the paused duration so the eased phase `t` is
284    /// continuous — a 50%-through sweep resumes at 50%, not snapped to
285    /// some other spot on the curve.
286    pub fn set_window_active(&mut self, active: bool, now: Instant) {
287        if self.window_active == active {
288            return;
289        }
290        if active {
291            if let Some(paused_at) = self.paused_at.take() {
292                let offset = now.saturating_duration_since(paused_at);
293                for anim in &mut self.animations {
294                    anim.start_time += offset;
295                    anim.next_tick = now;
296                }
297            }
298        } else {
299            self.paused_at = Some(now);
300        }
301        self.window_active = active;
302    }
303
304    pub fn is_window_active(&self) -> bool {
305        self.window_active
306    }
307
308    /// Move every stored instant from one time axis onto another, preserving
309    /// each animation's elapsed time across the move.
310    ///
311    /// Called when the tree switches the clock it measures animations against
312    /// — between the wall clock and its simulated one, in either direction.
313    /// Every instant this scheduler holds (`start_time`, `next_tick`,
314    /// `started_at`, and the pause mark) was taken on the axis that reads
315    /// `from` at this moment and has to be re-expressed on the one that reads
316    /// `to`, or the very next tick measures an elapsed time that includes the
317    /// whole gap between the two axes: forwards it completes every animation
318    /// at once, backwards it clamps every elapsed time to zero and nothing
319    /// moves again.
320    ///
321    /// Shifting backwards past the underlying clock's own origin is not
322    /// representable; such an instant is clamped to `to`, which costs that
323    /// animation its accumulated phase and nothing else. Reaching that clamp
324    /// takes a stored instant older than the platform's monotonic origin, so it
325    /// is unreachable in practice and deliberately left untested.
326    pub fn rebase(&mut self, from: Instant, to: Instant) {
327        if to == from {
328            return;
329        }
330        let shift = |instant: Instant| -> Instant {
331            if to >= from {
332                instant + (to - from)
333            } else {
334                instant.checked_sub(from - to).unwrap_or(to)
335            }
336        };
337        for anim in &mut self.animations {
338            anim.start_time = shift(anim.start_time);
339            anim.next_tick = shift(anim.next_tick);
340            anim.started_at = shift(anim.started_at);
341        }
342        if let Some(paused_at) = self.paused_at {
343            self.paused_at = Some(shift(paused_at));
344        }
345    }
346
347    /// Advance all active animations to the given time.
348    /// Returns true if any animation is still running *and eligible to
349    /// run next tick* (caller should request another frame).
350    ///
351    /// `arena` + `paint_epoch` gate per-widget visibility for **looping**
352    /// animations only: a continuous spinner whose owner widget is
353    /// dormant or hasn't been painted in the most recent paint pass
354    /// skips its tick. One-shot tweens are NOT visibility-gated — a
355    /// widget that animates its own size from zero (e.g. `Collapse`
356    /// growing from height=0 to natural) would otherwise be paused on
357    /// the very tick that would make it visible, locking it in the
358    /// invisible state forever. Pass `paint_epoch == 0` to disable the
359    /// gate entirely (headless tests that never call `render()`).
360    pub fn tick(&mut self, now: Instant, arena: &WidgetArena, paint_epoch: u64) -> bool {
361        if !self.window_active {
362            return !self.animations.is_empty();
363        }
364
365        self.animations.retain_mut(|anim| {
366            if !anim_widget_alive(arena, anim.widget_id) {
367                anim.signal.clear_animation_target();
368                return false;
369            }
370
371            if let Some(max) = anim.max_duration
372                && now.saturating_duration_since(anim.started_at) >= max
373            {
374                anim.signal.set(anim.start_value);
375                anim.signal.clear_animation_target();
376                return false;
377            }
378
379            if anim.looping && !anim_widget_visible(arena, anim.widget_id, paint_epoch) {
380                // Looping animation on an offscreen owner — pause to
381                // save CPU. Leave start_time alone so resume picks up
382                // mid-phase. Push next_tick so we don't spin on a
383                // paused entry when the scheduler is polled via some
384                // other deadline.
385                anim.next_tick = now + anim.frame_interval;
386                return true;
387            }
388
389            if now < anim.next_tick {
390                return true;
391            }
392
393            let elapsed = now.saturating_duration_since(anim.start_time);
394            let t = if anim.duration.is_zero() {
395                1.0
396            } else {
397                (elapsed.as_secs_f32() / anim.duration.as_secs_f32()).min(1.0)
398            };
399            let eased = anim.easing.apply(t);
400            let value = teksilo_tokens::lerp(anim.start_value, anim.end_value, eased);
401
402            let terminal = t >= 1.0;
403            // Terminal ticks always set unconditionally so we land exactly
404            // on end_value (or snap to start on loop restart); epsilon
405            // quantization only applies to intermediate ticks.
406            if terminal || (value - anim.last_set_value).abs() >= anim.epsilon {
407                anim.signal.set(value);
408                anim.last_set_value = value;
409            }
410
411            if t >= 1.0 && anim.looping {
412                anim.start_time = now;
413                anim.signal.set(anim.start_value);
414                anim.last_set_value = anim.start_value;
415                anim.next_tick = now + anim.frame_interval;
416                true
417            } else if t >= 1.0 {
418                anim.signal.clear_animation_target();
419                false
420            } else {
421                anim.next_tick = now + anim.frame_interval;
422                true
423            }
424        });
425
426        !self.animations.is_empty()
427    }
428
429    /// Whether any animation is currently stored in the scheduler
430    /// (ignores pause state — prefer `has_running`).
431    pub fn has_active(&self) -> bool {
432        !self.animations.is_empty()
433    }
434
435    /// Whether any animation is *eligible to advance* on the next tick:
436    /// stored AND the window is active. Used by the idle-work predicates
437    /// so a window-paused scheduler doesn't keep the event loop in
438    /// `ControlFlow::WaitUntil`.
439    ///
440    /// This does NOT check per-widget visibility (we'd need the arena
441    /// and the current paint epoch). An animation whose widget is
442    /// offscreen is still reported here; the per-widget gate lives in
443    /// `next_deadline` and `tick` directly.
444    pub fn has_running(&self) -> bool {
445        self.window_active && !self.animations.is_empty()
446    }
447
448    /// Earliest deadline at which a not-paused animation wants to
449    /// tick. Returns `None` when the scheduler is window-paused, all
450    /// (looping) animations are hidden, or there are no animations at
451    /// all. One-shot tweens are NOT visibility-gated — see the
452    /// matching note on [`tick`](Self::tick).
453    pub fn next_deadline(&self, arena: &WidgetArena, paint_epoch: u64) -> Option<Instant> {
454        if !self.window_active {
455            return None;
456        }
457        self.animations
458            .iter()
459            .filter(|anim| {
460                anim_widget_alive(arena, anim.widget_id)
461                    && (!anim.looping || anim_widget_visible(arena, anim.widget_id, paint_epoch))
462            })
463            .map(|anim| anim.next_tick)
464            .min()
465    }
466
467    /// Number of active animations (for testing/debugging).
468    pub fn active_count(&self) -> usize {
469        self.animations.len()
470    }
471}
472
473use crate::motion_visibility::{
474    alive as anim_widget_alive, painted_recently as anim_widget_visible,
475};
476
477impl Default for AnimationScheduler {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483impl std::fmt::Debug for AnimationScheduler {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        f.debug_struct("AnimationScheduler")
486            .field("active_count", &self.animations.len())
487            .field("window_active", &self.window_active)
488            .finish()
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::arena::WidgetArena;
496    use crate::test_widgets::FillWidget;
497
498    fn test_arena_with_widget() -> (WidgetArena, WidgetId) {
499        let mut arena = WidgetArena::new();
500        let id = arena.insert(Box::new(FillWidget::new()));
501        (arena, id)
502    }
503
504    #[test]
505    fn animate_from_current_to_target() {
506        let signal = Signal::<f32>::new_animated(0.0);
507        let mut scheduler = AnimationScheduler::new();
508        let (arena, id) = test_arena_with_widget();
509        let start = Instant::now();
510
511        scheduler.animate(
512            &signal,
513            id,
514            100.0,
515            Duration::from_millis(200),
516            Easing::Linear,
517            start,
518        );
519        assert_eq!(scheduler.active_count(), 1);
520
521        scheduler.tick(start, &arena, 0);
522        assert!((signal.get() - 0.0).abs() < 1.0);
523
524        let has_more = scheduler.tick(start + Duration::from_millis(100), &arena, 0);
525        assert!(has_more);
526        assert!((signal.get() - 50.0).abs() < 1.0);
527
528        let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
529        assert!(!has_more);
530        assert!((signal.get() - 100.0).abs() < 0.01);
531        assert_eq!(scheduler.active_count(), 0);
532    }
533
534    #[test]
535    fn eased_animation() {
536        let signal = Signal::<f32>::new_animated(0.0);
537        let mut scheduler = AnimationScheduler::new();
538        let (arena, id) = test_arena_with_widget();
539        let start = Instant::now();
540
541        scheduler.animate(
542            &signal,
543            id,
544            100.0,
545            Duration::from_millis(200),
546            Easing::EaseIn,
547            start,
548        );
549
550        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
551        assert!((signal.get() - 25.0).abs() < 1.0);
552    }
553
554    #[test]
555    fn zero_duration_sets_immediately() {
556        let signal = Signal::<f32>::new_animated(0.0);
557        let mut scheduler = AnimationScheduler::new();
558        let (_arena, id) = test_arena_with_widget();
559        let start = Instant::now();
560
561        scheduler.animate(&signal, id, 100.0, Duration::ZERO, Easing::Linear, start);
562        assert_eq!(scheduler.active_count(), 0);
563        assert!((signal.get() - 100.0).abs() < 0.01);
564    }
565
566    #[test]
567    fn replace_existing_animation() {
568        let signal = Signal::<f32>::new_animated(0.0);
569        let mut scheduler = AnimationScheduler::new();
570        let (arena, id) = test_arena_with_widget();
571        let start = Instant::now();
572
573        scheduler.animate(
574            &signal,
575            id,
576            100.0,
577            Duration::from_millis(200),
578            Easing::Linear,
579            start,
580        );
581
582        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
583        let mid_value = signal.get();
584        assert!((mid_value - 50.0).abs() < 1.0);
585
586        let mid_time = start + Duration::from_millis(100);
587        scheduler.animate(
588            &signal,
589            id,
590            0.0,
591            Duration::from_millis(100),
592            Easing::Linear,
593            mid_time,
594        );
595        assert_eq!(scheduler.active_count(), 1);
596
597        scheduler.tick(mid_time + Duration::from_millis(50), &arena, 0);
598        assert!((signal.get() - 25.0).abs() < 2.0);
599    }
600
601    #[test]
602    fn cancel_stops_animation() {
603        let signal = Signal::<f32>::new_animated(0.0);
604        let mut scheduler = AnimationScheduler::new();
605        let (_arena, id) = test_arena_with_widget();
606        let start = Instant::now();
607
608        scheduler.animate(
609            &signal,
610            id,
611            100.0,
612            Duration::from_millis(200),
613            Easing::Linear,
614            start,
615        );
616        assert_eq!(scheduler.active_count(), 1);
617
618        scheduler.cancel(&signal);
619        assert_eq!(scheduler.active_count(), 0);
620    }
621
622    #[test]
623    fn already_at_target_no_animation() {
624        let signal = Signal::<f32>::new_animated(50.0);
625        let mut scheduler = AnimationScheduler::new();
626        let (_arena, id) = test_arena_with_widget();
627        let start = Instant::now();
628
629        scheduler.animate(
630            &signal,
631            id,
632            50.0,
633            Duration::from_millis(200),
634            Easing::Linear,
635            start,
636        );
637        assert_eq!(scheduler.active_count(), 0);
638    }
639
640    #[test]
641    fn multiple_signals_animated_independently() {
642        let a = Signal::<f32>::new_animated(0.0);
643        let b = Signal::<f32>::new_animated(100.0);
644        let mut scheduler = AnimationScheduler::new();
645        let (arena, id) = test_arena_with_widget();
646        let start = Instant::now();
647
648        scheduler.animate(
649            &a,
650            id,
651            100.0,
652            Duration::from_millis(200),
653            Easing::Linear,
654            start,
655        );
656        scheduler.animate(
657            &b,
658            id,
659            0.0,
660            Duration::from_millis(200),
661            Easing::Linear,
662            start,
663        );
664        assert_eq!(scheduler.active_count(), 2);
665
666        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
667        assert!((a.get() - 50.0).abs() < 1.0);
668        assert!((b.get() - 50.0).abs() < 1.0);
669
670        scheduler.tick(start + Duration::from_millis(200), &arena, 0);
671        assert_eq!(scheduler.active_count(), 0);
672    }
673
674    #[test]
675    fn looping_animation_restarts() {
676        let signal = Signal::<f32>::new_animated(0.0);
677        let mut scheduler = AnimationScheduler::new();
678        let (arena, id) = test_arena_with_widget();
679        let start = Instant::now();
680
681        scheduler.animate_looping(
682            &signal,
683            id,
684            0.0,
685            100.0,
686            Duration::from_millis(200),
687            Easing::Linear,
688            None,
689            0.0,
690            None,
691            start,
692        );
693
694        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
695        assert!((signal.get() - 50.0).abs() < 1.0);
696
697        let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
698        assert!(has_more, "looping animation should keep running");
699        assert!(signal.get() < 5.0);
700
701        scheduler.tick(start + Duration::from_millis(300), &arena, 0);
702        assert!((signal.get() - 50.0).abs() < 5.0);
703    }
704
705    #[test]
706    fn looping_animation_cancelled() {
707        let signal = Signal::<f32>::new_animated(0.0);
708        let mut scheduler = AnimationScheduler::new();
709        let (_arena, id) = test_arena_with_widget();
710        let start = Instant::now();
711
712        scheduler.animate_looping(
713            &signal,
714            id,
715            0.0,
716            10.0,
717            Duration::from_millis(100),
718            Easing::Linear,
719            None,
720            0.0,
721            None,
722            start,
723        );
724        assert_eq!(scheduler.active_count(), 1);
725
726        scheduler.cancel(&signal);
727        assert_eq!(scheduler.active_count(), 0);
728    }
729
730    #[test]
731    fn cancel_by_widget_removes_all_animations_owned_by_widget() {
732        let a = Signal::<f32>::new_animated(0.0);
733        let b = Signal::<f32>::new_animated(0.0);
734        let c = Signal::<f32>::new_animated(0.0);
735        let mut scheduler = AnimationScheduler::new();
736        let mut arena = WidgetArena::new();
737        let id_x = arena.insert(Box::new(FillWidget::new()));
738        let id_y = arena.insert(Box::new(FillWidget::new()));
739        let now = Instant::now();
740
741        scheduler.animate(&a, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
742        scheduler.animate(&b, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
743        scheduler.animate(&c, id_y, 1.0, Duration::from_secs(1), Easing::Linear, now);
744        assert_eq!(scheduler.active_count(), 3);
745
746        scheduler.cancel_by_widget(id_x);
747        assert_eq!(scheduler.active_count(), 1);
748
749        // c (owned by id_y) still running
750        let _ = arena;
751    }
752
753    #[test]
754    fn window_inactive_pauses_tick() {
755        let signal = Signal::<f32>::new_animated(0.0);
756        let mut scheduler = AnimationScheduler::new();
757        let (arena, id) = test_arena_with_widget();
758        let start = Instant::now();
759
760        scheduler.animate(
761            &signal,
762            id,
763            100.0,
764            Duration::from_millis(200),
765            Easing::Linear,
766            start,
767        );
768        scheduler.set_window_active(false, start);
769
770        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
771        assert!(
772            signal.get() < 1.0,
773            "paused scheduler must not advance the signal"
774        );
775        assert!(scheduler.next_deadline(&arena, 0).is_none());
776    }
777
778    #[test]
779    fn resume_rebases_phase_continuously() {
780        let signal = Signal::<f32>::new_animated(0.0);
781        let mut scheduler = AnimationScheduler::new();
782        let (arena, id) = test_arena_with_widget();
783        let start = Instant::now();
784
785        scheduler.animate(
786            &signal,
787            id,
788            100.0,
789            Duration::from_millis(200),
790            Easing::Linear,
791            start,
792        );
793
794        // Advance halfway (t=0.5 → value≈50).
795        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
796        assert!((signal.get() - 50.0).abs() < 1.0);
797
798        // Window goes inactive at t=100ms.
799        scheduler.set_window_active(false, start + Duration::from_millis(100));
800
801        // 10 seconds of real time pass with window hidden. No ticks happen.
802        let resume_at = start + Duration::from_millis(100) + Duration::from_secs(10);
803        scheduler.set_window_active(true, resume_at);
804
805        // 50ms *after* resume, we should be at ≈75% of the eased curve,
806        // NOT at 100% (which is what we'd get without rebasing start_time).
807        scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
808        let after_resume = signal.get();
809        assert!(
810            (after_resume - 75.0).abs() < 2.0,
811            "expected phase-continuous resume ≈ 75, got {after_resume}"
812        );
813    }
814
815    #[test]
816    fn epsilon_skips_intermediate_sets_but_not_terminal() {
817        let signal = Signal::<f32>::new_animated(0.0);
818        let mut scheduler = AnimationScheduler::new();
819        let (arena, id) = test_arena_with_widget();
820        let start = Instant::now();
821
822        // ε = 10 → tick at t=0.05 produces value=5, below ε, should skip.
823        scheduler.animate_with_options(
824            &signal,
825            id,
826            100.0,
827            Duration::from_millis(200),
828            Easing::Linear,
829            None,
830            10.0,
831            None,
832            start,
833        );
834
835        scheduler.tick(start + Duration::from_millis(10), &arena, 0);
836        assert!(
837            signal.get() < 1.0,
838            "sub-ε tick should NOT call signal.set, signal.get() = {}",
839            signal.get()
840        );
841
842        // Terminal tick must set end_value regardless of ε.
843        scheduler.tick(start + Duration::from_millis(200), &arena, 0);
844        assert!(
845            (signal.get() - 100.0).abs() < 0.01,
846            "terminal tick must bypass ε and land exactly on end"
847        );
848    }
849
850    #[test]
851    fn max_duration_snaps_to_start_and_drops() {
852        let signal = Signal::<f32>::new_animated(0.0);
853        let mut scheduler = AnimationScheduler::new();
854        let (arena, id) = test_arena_with_widget();
855        let start = Instant::now();
856
857        scheduler.animate_looping(
858            &signal,
859            id,
860            0.0,
861            100.0,
862            Duration::from_millis(200),
863            Easing::Linear,
864            None,
865            0.0,
866            Some(Duration::from_secs(1)),
867            start,
868        );
869
870        scheduler.tick(start + Duration::from_millis(100), &arena, 0);
871        assert!(signal.get() > 0.0);
872
873        // 1.5s past start-at: past the 1s cap.
874        let has_more = scheduler.tick(start + Duration::from_millis(1500), &arena, 0);
875        assert!(!has_more, "capped animation should drop");
876        assert_eq!(scheduler.active_count(), 0);
877        assert!(
878            signal.get().abs() < 0.01,
879            "capped animation should snap to start_value (0), got {}",
880            signal.get()
881        );
882    }
883
884    #[test]
885    fn one_shot_tween_runs_even_when_owner_appears_offscreen() {
886        // Regression: a `Collapse`-style widget animating its own
887        // height from 0 → natural would set up a one-shot tween whose
888        // owner is the Collapse widget itself. Because the widget's
889        // current bounds are zero, the paint pass would skip it,
890        // never stamping `last_painted_epoch`. The visibility gate
891        // would then see `lpe + 1 < paint_epoch` and pause the
892        // animation forever — locking the widget at height=0.
893        let signal = Signal::<f32>::new_animated(0.0);
894        let mut scheduler = AnimationScheduler::new();
895        let (mut arena, id) = test_arena_with_widget();
896        // Simulate a never-painted widget (lpe stays at the default 0)
897        // while the global paint_epoch has advanced many frames.
898        let _ = arena.get_mut(id); // ensure node exists; lpe defaults to 0
899        let start = Instant::now();
900        let paint_epoch: u64 = 42; // many frames have passed
901
902        scheduler.animate(
903            &signal,
904            id,
905            100.0,
906            Duration::from_millis(200),
907            Easing::Linear,
908            start,
909        );
910
911        scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
912        assert!(
913            signal.get() > 10.0,
914            "one-shot tween must progress despite owner having stale paint_epoch (got {})",
915            signal.get()
916        );
917
918        scheduler.tick(start + Duration::from_millis(200), &arena, paint_epoch);
919        assert!(
920            (signal.get() - 100.0).abs() < 0.01,
921            "one-shot tween must reach target (got {})",
922            signal.get()
923        );
924    }
925
926    #[test]
927    fn looping_animation_pauses_when_owner_offscreen() {
928        // The looping case (e.g. spinner in a hidden tab) keeps the
929        // visibility gate: we don't want a hidden spinner to burn CPU
930        // ticking against a widget the user can't see.
931        let signal = Signal::<f32>::new_animated(0.0);
932        let mut scheduler = AnimationScheduler::new();
933        let (arena, id) = test_arena_with_widget();
934        let start = Instant::now();
935        let paint_epoch: u64 = 42;
936
937        scheduler.animate_looping(
938            &signal,
939            id,
940            0.0,
941            100.0,
942            Duration::from_millis(200),
943            Easing::Linear,
944            None,
945            0.0,
946            None,
947            start,
948        );
949
950        // With paint_epoch high and lpe at 0, the loop must NOT
951        // progress — the value stays at the start.
952        scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
953        assert!(
954            signal.get().abs() < 0.01,
955            "looping animation should pause for offscreen owner (got {})",
956            signal.get()
957        );
958    }
959}