1use 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#[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 pub looping: bool,
46 pub epsilon: f32,
51 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
71struct 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 looping: bool,
86 epsilon: f32,
87 last_set_value: f32,
88 started_at: Instant,
94 max_duration: Option<Duration>,
95}
96
97const DEFAULT_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
107
108pub struct AnimationScheduler {
110 animations: Vec<ActiveAnimation>,
111 window_active: bool,
117 paused_at: Option<Instant>,
125}
126
127impl AnimationScheduler {
128 pub fn new() -> Self {
129 Self {
130 animations: Vec::new(),
131 window_active: true,
132 paused_at: None,
133 }
134 }
135
136 pub fn animate(
140 &mut self,
141 signal: &Signal<f32>,
142 widget_id: WidgetId,
143 target: f32,
144 duration: Duration,
145 easing: Easing,
146 now: Instant,
147 ) {
148 self.animate_with_options(
149 signal, widget_id, target, duration, easing, None, 0.0, None, now,
150 );
151 }
152
153 #[allow(clippy::too_many_arguments)]
154 pub fn animate_with_options(
155 &mut self,
156 signal: &Signal<f32>,
157 widget_id: WidgetId,
158 target: f32,
159 duration: Duration,
160 easing: Easing,
161 frame_interval: Option<Duration>,
162 epsilon: f32,
163 max_duration: Option<Duration>,
164 now: Instant,
165 ) {
166 if let Some(existing) = self
176 .animations
177 .iter()
178 .find(|a| Signal::same(&a.signal, signal))
179 && !existing.looping
180 {
181 let elapsed = now.saturating_duration_since(existing.start_time);
182 let t = if existing.duration.is_zero() {
183 1.0
184 } else {
185 (elapsed.as_secs_f32() / existing.duration.as_secs_f32()).min(1.0)
186 };
187 let eased = existing.easing.apply(t);
188 let value = teksilo_tokens::lerp(existing.start_value, existing.end_value, eased);
189 signal.set(value);
190 }
191
192 let current = signal.get();
193 self.cancel(signal);
194
195 if (current - target).abs() < f32::EPSILON || duration.is_zero() {
196 signal.set(target);
197 signal.clear_animation_target();
198 return;
199 }
200
201 self.animations.push(ActiveAnimation {
202 widget_id,
203 signal: signal.clone(),
204 start_value: current,
205 end_value: target,
206 start_time: now,
207 duration,
208 easing,
209 frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
210 next_tick: now,
211 looping: false,
212 epsilon,
213 last_set_value: current,
214 started_at: now,
215 max_duration,
216 });
217 }
218
219 #[allow(clippy::too_many_arguments)]
223 pub fn animate_looping(
224 &mut self,
225 signal: &Signal<f32>,
226 widget_id: WidgetId,
227 start: f32,
228 end: f32,
229 period: Duration,
230 easing: Easing,
231 frame_interval: Option<Duration>,
232 epsilon: f32,
233 max_duration: Option<Duration>,
234 now: Instant,
235 ) {
236 self.cancel(signal);
237 signal.set(start);
238
239 self.animations.push(ActiveAnimation {
240 widget_id,
241 signal: signal.clone(),
242 start_value: start,
243 end_value: end,
244 start_time: now,
245 duration: period,
246 easing,
247 frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
248 next_tick: now,
249 looping: true,
250 epsilon,
251 last_set_value: start,
252 started_at: now,
253 max_duration,
254 });
255 }
256
257 pub fn cancel(&mut self, signal: &Signal<f32>) {
259 self.animations.retain(|a| !Signal::same(&a.signal, signal));
260 }
261
262 pub fn cancel_by_widget(&mut self, widget_id: WidgetId) {
270 self.animations.retain(|a| {
271 if a.widget_id == widget_id {
272 a.signal.clear_animation_target();
273 false
274 } else {
275 true
276 }
277 });
278 }
279
280 pub fn set_window_active(&mut self, active: bool, now: Instant) {
298 if self.window_active == active {
299 return;
300 }
301 if active {
302 if let Some(paused_at) = self.paused_at.take() {
303 for anim in self.animations.iter_mut().filter(|anim| anim.looping) {
304 let offset = now.saturating_duration_since(paused_at.max(anim.start_time));
310 anim.start_time += offset;
311 anim.next_tick = now;
312 }
313 }
314 } else {
315 self.paused_at = Some(now);
316 }
317 self.window_active = active;
318 }
319
320 pub fn is_window_active(&self) -> bool {
321 self.window_active
322 }
323
324 pub fn rebase(&mut self, from: Instant, to: Instant) {
343 if to == from {
344 return;
345 }
346 let shift = |instant: Instant| -> Instant {
347 if to >= from {
348 instant + (to - from)
349 } else {
350 instant.checked_sub(from - to).unwrap_or(to)
351 }
352 };
353 for anim in &mut self.animations {
354 anim.start_time = shift(anim.start_time);
355 anim.next_tick = shift(anim.next_tick);
356 anim.started_at = shift(anim.started_at);
357 }
358 if let Some(paused_at) = self.paused_at {
359 self.paused_at = Some(shift(paused_at));
360 }
361 }
362
363 pub fn tick(&mut self, now: Instant, arena: &WidgetArena, paint_epoch: u64) -> bool {
380 let window_active = self.window_active;
381 self.animations.retain_mut(|anim| {
382 if !anim_widget_alive(arena, anim.widget_id) {
383 anim.signal.clear_animation_target();
384 return false;
385 }
386
387 if let Some(max) = anim.max_duration
388 && now.saturating_duration_since(anim.started_at) >= max
389 {
390 anim.signal.set(anim.start_value);
391 anim.signal.clear_animation_target();
392 return false;
393 }
394
395 if anim.looping && !window_active {
396 return true;
400 }
401
402 if anim.looping && !anim_widget_visible(arena, anim.widget_id, paint_epoch) {
403 anim.next_tick = now + anim.frame_interval;
409 return true;
410 }
411
412 if now < anim.next_tick {
413 return true;
414 }
415
416 let elapsed = now.saturating_duration_since(anim.start_time);
417 let t = if anim.duration.is_zero() {
418 1.0
419 } else {
420 (elapsed.as_secs_f32() / anim.duration.as_secs_f32()).min(1.0)
421 };
422 let eased = anim.easing.apply(t);
423 let value = teksilo_tokens::lerp(anim.start_value, anim.end_value, eased);
424
425 let terminal = t >= 1.0;
426 if terminal || (value - anim.last_set_value).abs() >= anim.epsilon {
430 anim.signal.set(value);
431 anim.last_set_value = value;
432 }
433
434 if t >= 1.0 && anim.looping {
435 anim.start_time = now;
436 anim.signal.set(anim.start_value);
437 anim.last_set_value = anim.start_value;
438 anim.next_tick = now + anim.frame_interval;
439 true
440 } else if t >= 1.0 {
441 anim.signal.clear_animation_target();
442 false
443 } else {
444 anim.next_tick = now + anim.frame_interval;
445 true
446 }
447 });
448
449 self.has_running()
450 }
451
452 pub fn has_active(&self) -> bool {
455 !self.animations.is_empty()
456 }
457
458 pub fn has_running(&self) -> bool {
469 self.animations
470 .iter()
471 .any(|anim| self.window_active || !anim.looping)
472 }
473
474 pub fn next_deadline(&self, arena: &WidgetArena, paint_epoch: u64) -> Option<Instant> {
481 self.animations
482 .iter()
483 .filter(|anim| {
484 anim_widget_alive(arena, anim.widget_id)
485 && (!anim.looping
486 || (self.window_active
487 && anim_widget_visible(arena, anim.widget_id, paint_epoch)))
488 })
489 .map(|anim| anim.next_tick)
490 .min()
491 }
492
493 pub fn active_count(&self) -> usize {
495 self.animations.len()
496 }
497}
498
499use crate::motion_visibility::{
500 alive as anim_widget_alive, painted_recently as anim_widget_visible,
501};
502
503impl Default for AnimationScheduler {
504 fn default() -> Self {
505 Self::new()
506 }
507}
508
509impl std::fmt::Debug for AnimationScheduler {
510 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511 f.debug_struct("AnimationScheduler")
512 .field("active_count", &self.animations.len())
513 .field("window_active", &self.window_active)
514 .finish()
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::arena::WidgetArena;
522 use crate::test_widgets::FillWidget;
523
524 fn test_arena_with_widget() -> (WidgetArena, WidgetId) {
525 let mut arena = WidgetArena::new();
526 let id = arena.insert(Box::new(FillWidget::new()));
527 (arena, id)
528 }
529
530 #[test]
531 fn animate_from_current_to_target() {
532 let signal = Signal::<f32>::new_animated(0.0);
533 let mut scheduler = AnimationScheduler::new();
534 let (arena, id) = test_arena_with_widget();
535 let start = Instant::now();
536
537 scheduler.animate(
538 &signal,
539 id,
540 100.0,
541 Duration::from_millis(200),
542 Easing::Linear,
543 start,
544 );
545 assert_eq!(scheduler.active_count(), 1);
546
547 scheduler.tick(start, &arena, 0);
548 assert!((signal.get() - 0.0).abs() < 1.0);
549
550 let has_more = scheduler.tick(start + Duration::from_millis(100), &arena, 0);
551 assert!(has_more);
552 assert!((signal.get() - 50.0).abs() < 1.0);
553
554 let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
555 assert!(!has_more);
556 assert!((signal.get() - 100.0).abs() < 0.01);
557 assert_eq!(scheduler.active_count(), 0);
558 }
559
560 #[test]
561 fn eased_animation() {
562 let signal = Signal::<f32>::new_animated(0.0);
563 let mut scheduler = AnimationScheduler::new();
564 let (arena, id) = test_arena_with_widget();
565 let start = Instant::now();
566
567 scheduler.animate(
568 &signal,
569 id,
570 100.0,
571 Duration::from_millis(200),
572 Easing::EaseIn,
573 start,
574 );
575
576 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
577 assert!((signal.get() - 25.0).abs() < 1.0);
578 }
579
580 #[test]
581 fn zero_duration_sets_immediately() {
582 let signal = Signal::<f32>::new_animated(0.0);
583 let mut scheduler = AnimationScheduler::new();
584 let (_arena, id) = test_arena_with_widget();
585 let start = Instant::now();
586
587 scheduler.animate(&signal, id, 100.0, Duration::ZERO, Easing::Linear, start);
588 assert_eq!(scheduler.active_count(), 0);
589 assert!((signal.get() - 100.0).abs() < 0.01);
590 }
591
592 #[test]
593 fn replace_existing_animation() {
594 let signal = Signal::<f32>::new_animated(0.0);
595 let mut scheduler = AnimationScheduler::new();
596 let (arena, id) = test_arena_with_widget();
597 let start = Instant::now();
598
599 scheduler.animate(
600 &signal,
601 id,
602 100.0,
603 Duration::from_millis(200),
604 Easing::Linear,
605 start,
606 );
607
608 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
609 let mid_value = signal.get();
610 assert!((mid_value - 50.0).abs() < 1.0);
611
612 let mid_time = start + Duration::from_millis(100);
613 scheduler.animate(
614 &signal,
615 id,
616 0.0,
617 Duration::from_millis(100),
618 Easing::Linear,
619 mid_time,
620 );
621 assert_eq!(scheduler.active_count(), 1);
622
623 scheduler.tick(mid_time + Duration::from_millis(50), &arena, 0);
624 assert!((signal.get() - 25.0).abs() < 2.0);
625 }
626
627 #[test]
628 fn cancel_stops_animation() {
629 let signal = Signal::<f32>::new_animated(0.0);
630 let mut scheduler = AnimationScheduler::new();
631 let (_arena, id) = test_arena_with_widget();
632 let start = Instant::now();
633
634 scheduler.animate(
635 &signal,
636 id,
637 100.0,
638 Duration::from_millis(200),
639 Easing::Linear,
640 start,
641 );
642 assert_eq!(scheduler.active_count(), 1);
643
644 scheduler.cancel(&signal);
645 assert_eq!(scheduler.active_count(), 0);
646 }
647
648 #[test]
649 fn already_at_target_no_animation() {
650 let signal = Signal::<f32>::new_animated(50.0);
651 let mut scheduler = AnimationScheduler::new();
652 let (_arena, id) = test_arena_with_widget();
653 let start = Instant::now();
654
655 scheduler.animate(
656 &signal,
657 id,
658 50.0,
659 Duration::from_millis(200),
660 Easing::Linear,
661 start,
662 );
663 assert_eq!(scheduler.active_count(), 0);
664 }
665
666 #[test]
667 fn multiple_signals_animated_independently() {
668 let a = Signal::<f32>::new_animated(0.0);
669 let b = Signal::<f32>::new_animated(100.0);
670 let mut scheduler = AnimationScheduler::new();
671 let (arena, id) = test_arena_with_widget();
672 let start = Instant::now();
673
674 scheduler.animate(
675 &a,
676 id,
677 100.0,
678 Duration::from_millis(200),
679 Easing::Linear,
680 start,
681 );
682 scheduler.animate(
683 &b,
684 id,
685 0.0,
686 Duration::from_millis(200),
687 Easing::Linear,
688 start,
689 );
690 assert_eq!(scheduler.active_count(), 2);
691
692 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
693 assert!((a.get() - 50.0).abs() < 1.0);
694 assert!((b.get() - 50.0).abs() < 1.0);
695
696 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
697 assert_eq!(scheduler.active_count(), 0);
698 }
699
700 #[test]
701 fn looping_animation_restarts() {
702 let signal = Signal::<f32>::new_animated(0.0);
703 let mut scheduler = AnimationScheduler::new();
704 let (arena, id) = test_arena_with_widget();
705 let start = Instant::now();
706
707 scheduler.animate_looping(
708 &signal,
709 id,
710 0.0,
711 100.0,
712 Duration::from_millis(200),
713 Easing::Linear,
714 None,
715 0.0,
716 None,
717 start,
718 );
719
720 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
721 assert!((signal.get() - 50.0).abs() < 1.0);
722
723 let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
724 assert!(has_more, "looping animation should keep running");
725 assert!(signal.get() < 5.0);
726
727 scheduler.tick(start + Duration::from_millis(300), &arena, 0);
728 assert!((signal.get() - 50.0).abs() < 5.0);
729 }
730
731 #[test]
732 fn looping_animation_cancelled() {
733 let signal = Signal::<f32>::new_animated(0.0);
734 let mut scheduler = AnimationScheduler::new();
735 let (_arena, id) = test_arena_with_widget();
736 let start = Instant::now();
737
738 scheduler.animate_looping(
739 &signal,
740 id,
741 0.0,
742 10.0,
743 Duration::from_millis(100),
744 Easing::Linear,
745 None,
746 0.0,
747 None,
748 start,
749 );
750 assert_eq!(scheduler.active_count(), 1);
751
752 scheduler.cancel(&signal);
753 assert_eq!(scheduler.active_count(), 0);
754 }
755
756 #[test]
757 fn cancel_by_widget_removes_all_animations_owned_by_widget() {
758 let a = Signal::<f32>::new_animated(0.0);
759 let b = Signal::<f32>::new_animated(0.0);
760 let c = Signal::<f32>::new_animated(0.0);
761 let mut scheduler = AnimationScheduler::new();
762 let mut arena = WidgetArena::new();
763 let id_x = arena.insert(Box::new(FillWidget::new()));
764 let id_y = arena.insert(Box::new(FillWidget::new()));
765 let now = Instant::now();
766
767 scheduler.animate(&a, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
768 scheduler.animate(&b, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
769 scheduler.animate(&c, id_y, 1.0, Duration::from_secs(1), Easing::Linear, now);
770 assert_eq!(scheduler.active_count(), 3);
771
772 scheduler.cancel_by_widget(id_x);
773 assert_eq!(scheduler.active_count(), 1);
774
775 let _ = arena;
777 }
778
779 fn start_loop(
781 scheduler: &mut AnimationScheduler,
782 signal: &Signal<f32>,
783 id: WidgetId,
784 start: Instant,
785 ) {
786 scheduler.animate_looping(
787 signal,
788 id,
789 0.0,
790 100.0,
791 Duration::from_millis(200),
792 Easing::Linear,
793 None,
794 0.0,
795 None,
796 start,
797 );
798 }
799
800 #[test]
801 fn window_inactive_pauses_a_looping_tick() {
802 let signal = Signal::<f32>::new_animated(0.0);
803 let mut scheduler = AnimationScheduler::new();
804 let (arena, id) = test_arena_with_widget();
805 let start = Instant::now();
806
807 start_loop(&mut scheduler, &signal, id, start);
808 scheduler.set_window_active(false, start);
809
810 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
811 assert!(
812 signal.get() < 1.0,
813 "paused scheduler must not advance a loop"
814 );
815 assert!(scheduler.next_deadline(&arena, 0).is_none());
816 assert!(
817 !scheduler.has_running(),
818 "a paused loop must not keep the event loop awake"
819 );
820 }
821
822 #[test]
828 fn a_one_shot_tween_runs_on_in_an_inactive_window() {
829 let signal = Signal::<f32>::new_animated(0.0);
830 let mut scheduler = AnimationScheduler::new();
831 let (arena, id) = test_arena_with_widget();
832 let start = Instant::now();
833
834 scheduler.set_window_active(false, start);
835 scheduler.animate(
836 &signal,
837 id,
838 100.0,
839 Duration::from_millis(200),
840 Easing::Linear,
841 start,
842 );
843 assert!(
844 scheduler.has_running(),
845 "the tween must keep the event loop awake"
846 );
847 assert_eq!(
848 scheduler.next_deadline(&arena, 0),
849 Some(start),
850 "the tween must ask for its first frame"
851 );
852
853 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
854 assert!(
855 (signal.get() - 50.0).abs() < 1.0,
856 "half way after 100 ms of 200, got {}",
857 signal.get()
858 );
859 let more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
860 assert!((signal.get() - 100.0).abs() < 0.01, "lands on its target");
861 assert!(!more, "and leaves the scheduler");
862 }
863
864 #[test]
867 fn a_one_shot_tween_keeps_its_phase_across_a_focus_change() {
868 let signal = Signal::<f32>::new_animated(0.0);
869 let mut scheduler = AnimationScheduler::new();
870 let (arena, id) = test_arena_with_widget();
871 let start = Instant::now();
872
873 scheduler.animate(
874 &signal,
875 id,
876 100.0,
877 Duration::from_millis(400),
878 Easing::Linear,
879 start,
880 );
881 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
882 scheduler.set_window_active(false, start + Duration::from_millis(100));
883 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
884 assert!(
885 (signal.get() - 50.0).abs() < 1.0,
886 "still moving without focus, got {}",
887 signal.get()
888 );
889 scheduler.set_window_active(true, start + Duration::from_millis(250));
890 scheduler.tick(start + Duration::from_millis(300), &arena, 0);
891 assert!(
892 (signal.get() - 75.0).abs() < 1.0,
893 "on the same schedule once focused again, got {}",
894 signal.get()
895 );
896 }
897
898 #[test]
902 fn a_loop_started_in_an_inactive_window_starts_on_resume() {
903 let signal = Signal::<f32>::new_animated(0.0);
904 let mut scheduler = AnimationScheduler::new();
905 let (arena, id) = test_arena_with_widget();
906 let start = Instant::now();
907
908 scheduler.set_window_active(false, start);
909 start_loop(&mut scheduler, &signal, id, start + Duration::from_secs(5));
910 let resume_at = start + Duration::from_secs(10);
911 scheduler.set_window_active(true, resume_at);
912 scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
913 assert!(
914 (signal.get() - 25.0).abs() < 1.0,
915 "a quarter through 50 ms after the window got focus, got {}",
916 signal.get()
917 );
918 }
919
920 #[test]
921 fn resume_rebases_phase_continuously() {
922 let signal = Signal::<f32>::new_animated(0.0);
923 let mut scheduler = AnimationScheduler::new();
924 let (arena, id) = test_arena_with_widget();
925 let start = Instant::now();
926
927 start_loop(&mut scheduler, &signal, id, start);
928
929 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
931 assert!((signal.get() - 50.0).abs() < 1.0);
932
933 scheduler.set_window_active(false, start + Duration::from_millis(100));
935
936 let resume_at = start + Duration::from_millis(100) + Duration::from_secs(10);
938 scheduler.set_window_active(true, resume_at);
939
940 scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
943 let after_resume = signal.get();
944 assert!(
945 (after_resume - 75.0).abs() < 2.0,
946 "expected phase-continuous resume ≈ 75, got {after_resume}"
947 );
948 }
949
950 #[test]
951 fn epsilon_skips_intermediate_sets_but_not_terminal() {
952 let signal = Signal::<f32>::new_animated(0.0);
953 let mut scheduler = AnimationScheduler::new();
954 let (arena, id) = test_arena_with_widget();
955 let start = Instant::now();
956
957 scheduler.animate_with_options(
959 &signal,
960 id,
961 100.0,
962 Duration::from_millis(200),
963 Easing::Linear,
964 None,
965 10.0,
966 None,
967 start,
968 );
969
970 scheduler.tick(start + Duration::from_millis(10), &arena, 0);
971 assert!(
972 signal.get() < 1.0,
973 "sub-ε tick should NOT call signal.set, signal.get() = {}",
974 signal.get()
975 );
976
977 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
979 assert!(
980 (signal.get() - 100.0).abs() < 0.01,
981 "terminal tick must bypass ε and land exactly on end"
982 );
983 }
984
985 #[test]
986 fn max_duration_snaps_to_start_and_drops() {
987 let signal = Signal::<f32>::new_animated(0.0);
988 let mut scheduler = AnimationScheduler::new();
989 let (arena, id) = test_arena_with_widget();
990 let start = Instant::now();
991
992 scheduler.animate_looping(
993 &signal,
994 id,
995 0.0,
996 100.0,
997 Duration::from_millis(200),
998 Easing::Linear,
999 None,
1000 0.0,
1001 Some(Duration::from_secs(1)),
1002 start,
1003 );
1004
1005 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
1006 assert!(signal.get() > 0.0);
1007
1008 let has_more = scheduler.tick(start + Duration::from_millis(1500), &arena, 0);
1010 assert!(!has_more, "capped animation should drop");
1011 assert_eq!(scheduler.active_count(), 0);
1012 assert!(
1013 signal.get().abs() < 0.01,
1014 "capped animation should snap to start_value (0), got {}",
1015 signal.get()
1016 );
1017 }
1018
1019 #[test]
1020 fn one_shot_tween_runs_even_when_owner_appears_offscreen() {
1021 let signal = Signal::<f32>::new_animated(0.0);
1029 let mut scheduler = AnimationScheduler::new();
1030 let (mut arena, id) = test_arena_with_widget();
1031 let _ = arena.get_mut(id); let start = Instant::now();
1035 let paint_epoch: u64 = 42; scheduler.animate(
1038 &signal,
1039 id,
1040 100.0,
1041 Duration::from_millis(200),
1042 Easing::Linear,
1043 start,
1044 );
1045
1046 scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
1047 assert!(
1048 signal.get() > 10.0,
1049 "one-shot tween must progress despite owner having stale paint_epoch (got {})",
1050 signal.get()
1051 );
1052
1053 scheduler.tick(start + Duration::from_millis(200), &arena, paint_epoch);
1054 assert!(
1055 (signal.get() - 100.0).abs() < 0.01,
1056 "one-shot tween must reach target (got {})",
1057 signal.get()
1058 );
1059 }
1060
1061 #[test]
1062 fn looping_animation_pauses_when_owner_offscreen() {
1063 let signal = Signal::<f32>::new_animated(0.0);
1067 let mut scheduler = AnimationScheduler::new();
1068 let (arena, id) = test_arena_with_widget();
1069 let start = Instant::now();
1070 let paint_epoch: u64 = 42;
1071
1072 scheduler.animate_looping(
1073 &signal,
1074 id,
1075 0.0,
1076 100.0,
1077 Duration::from_millis(200),
1078 Easing::Linear,
1079 None,
1080 0.0,
1081 None,
1082 start,
1083 );
1084
1085 scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
1088 assert!(
1089 signal.get().abs() < 0.01,
1090 "looping animation should pause for offscreen owner (got {})",
1091 signal.get()
1092 );
1093 }
1094}