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