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,
92 max_duration: Option<Duration>,
93}
94
95const DEFAULT_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
105
106pub struct AnimationScheduler {
108 animations: Vec<ActiveAnimation>,
109 window_active: bool,
114 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 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 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 #[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 pub fn cancel(&mut self, signal: &Signal<f32>) {
252 self.animations.retain(|a| !Signal::same(&a.signal, signal));
253 }
254
255 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 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 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 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 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 pub fn has_active(&self) -> bool {
387 !self.animations.is_empty()
388 }
389
390 pub fn has_running(&self) -> bool {
400 self.window_active && !self.animations.is_empty()
401 }
402
403 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 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 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 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
751 assert!((signal.get() - 50.0).abs() < 1.0);
752
753 scheduler.set_window_active(false, start + Duration::from_millis(100));
755
756 let resume_at = start + Duration::from_millis(100) + Duration::from_secs(10);
758 scheduler.set_window_active(true, resume_at);
759
760 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 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 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 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 let signal = Signal::<f32>::new_animated(0.0);
849 let mut scheduler = AnimationScheduler::new();
850 let (mut arena, id) = test_arena_with_widget();
851 let _ = arena.get_mut(id); let start = Instant::now();
855 let paint_epoch: u64 = 42; 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 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 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}