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