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,
116 paused_at: Option<Instant>,
124}
125
126impl AnimationScheduler {
127 pub fn new() -> Self {
128 Self {
129 animations: Vec::new(),
130 window_active: true,
131 paused_at: None,
132 }
133 }
134
135 pub fn animate(
139 &mut self,
140 signal: &Signal<f32>,
141 widget_id: WidgetId,
142 target: f32,
143 duration: Duration,
144 easing: Easing,
145 now: Instant,
146 ) {
147 self.animate_with_options(
148 signal, widget_id, target, duration, easing, None, 0.0, None, now,
149 );
150 }
151
152 #[allow(clippy::too_many_arguments)]
153 pub fn animate_with_options(
154 &mut self,
155 signal: &Signal<f32>,
156 widget_id: WidgetId,
157 target: f32,
158 duration: Duration,
159 easing: Easing,
160 frame_interval: Option<Duration>,
161 epsilon: f32,
162 max_duration: Option<Duration>,
163 now: Instant,
164 ) {
165 if let Some(existing) = self
175 .animations
176 .iter()
177 .find(|a| Signal::same(&a.signal, signal))
178 && !existing.looping
179 {
180 let elapsed = now.saturating_duration_since(existing.start_time);
181 let t = if existing.duration.is_zero() {
182 1.0
183 } else {
184 (elapsed.as_secs_f32() / existing.duration.as_secs_f32()).min(1.0)
185 };
186 let eased = existing.easing.apply(t);
187 let value = teksilo_tokens::lerp(existing.start_value, existing.end_value, eased);
188 signal.set(value);
189 }
190
191 let current = signal.get();
192 self.cancel(signal);
193
194 if (current - target).abs() < f32::EPSILON || duration.is_zero() {
195 signal.set(target);
196 signal.clear_animation_target();
197 return;
198 }
199
200 self.animations.push(ActiveAnimation {
201 widget_id,
202 signal: signal.clone(),
203 start_value: current,
204 end_value: target,
205 start_time: now,
206 duration,
207 easing,
208 frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
209 next_tick: now,
210 looping: false,
211 epsilon,
212 last_set_value: current,
213 started_at: now,
214 max_duration,
215 });
216 }
217
218 #[allow(clippy::too_many_arguments)]
222 pub fn animate_looping(
223 &mut self,
224 signal: &Signal<f32>,
225 widget_id: WidgetId,
226 start: f32,
227 end: f32,
228 period: Duration,
229 easing: Easing,
230 frame_interval: Option<Duration>,
231 epsilon: f32,
232 max_duration: Option<Duration>,
233 now: Instant,
234 ) {
235 self.cancel(signal);
236 signal.set(start);
237
238 self.animations.push(ActiveAnimation {
239 widget_id,
240 signal: signal.clone(),
241 start_value: start,
242 end_value: end,
243 start_time: now,
244 duration: period,
245 easing,
246 frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
247 next_tick: now,
248 looping: true,
249 epsilon,
250 last_set_value: start,
251 started_at: now,
252 max_duration,
253 });
254 }
255
256 pub fn cancel(&mut self, signal: &Signal<f32>) {
258 self.animations.retain(|a| !Signal::same(&a.signal, signal));
259 }
260
261 pub fn cancel_by_widget(&mut self, widget_id: WidgetId) {
269 self.animations.retain(|a| {
270 if a.widget_id == widget_id {
271 a.signal.clear_animation_target();
272 false
273 } else {
274 true
275 }
276 });
277 }
278
279 pub fn set_window_active(&mut self, active: bool, now: Instant) {
287 if self.window_active == active {
288 return;
289 }
290 if active {
291 if let Some(paused_at) = self.paused_at.take() {
292 let offset = now.saturating_duration_since(paused_at);
293 for anim in &mut self.animations {
294 anim.start_time += offset;
295 anim.next_tick = now;
296 }
297 }
298 } else {
299 self.paused_at = Some(now);
300 }
301 self.window_active = active;
302 }
303
304 pub fn is_window_active(&self) -> bool {
305 self.window_active
306 }
307
308 pub fn rebase(&mut self, from: Instant, to: Instant) {
327 if to == from {
328 return;
329 }
330 let shift = |instant: Instant| -> Instant {
331 if to >= from {
332 instant + (to - from)
333 } else {
334 instant.checked_sub(from - to).unwrap_or(to)
335 }
336 };
337 for anim in &mut self.animations {
338 anim.start_time = shift(anim.start_time);
339 anim.next_tick = shift(anim.next_tick);
340 anim.started_at = shift(anim.started_at);
341 }
342 if let Some(paused_at) = self.paused_at {
343 self.paused_at = Some(shift(paused_at));
344 }
345 }
346
347 pub fn tick(&mut self, now: Instant, arena: &WidgetArena, paint_epoch: u64) -> bool {
361 if !self.window_active {
362 return !self.animations.is_empty();
363 }
364
365 self.animations.retain_mut(|anim| {
366 if !anim_widget_alive(arena, anim.widget_id) {
367 anim.signal.clear_animation_target();
368 return false;
369 }
370
371 if let Some(max) = anim.max_duration
372 && now.saturating_duration_since(anim.started_at) >= max
373 {
374 anim.signal.set(anim.start_value);
375 anim.signal.clear_animation_target();
376 return false;
377 }
378
379 if anim.looping && !anim_widget_visible(arena, anim.widget_id, paint_epoch) {
380 anim.next_tick = now + anim.frame_interval;
386 return true;
387 }
388
389 if now < anim.next_tick {
390 return true;
391 }
392
393 let elapsed = now.saturating_duration_since(anim.start_time);
394 let t = if anim.duration.is_zero() {
395 1.0
396 } else {
397 (elapsed.as_secs_f32() / anim.duration.as_secs_f32()).min(1.0)
398 };
399 let eased = anim.easing.apply(t);
400 let value = teksilo_tokens::lerp(anim.start_value, anim.end_value, eased);
401
402 let terminal = t >= 1.0;
403 if terminal || (value - anim.last_set_value).abs() >= anim.epsilon {
407 anim.signal.set(value);
408 anim.last_set_value = value;
409 }
410
411 if t >= 1.0 && anim.looping {
412 anim.start_time = now;
413 anim.signal.set(anim.start_value);
414 anim.last_set_value = anim.start_value;
415 anim.next_tick = now + anim.frame_interval;
416 true
417 } else if t >= 1.0 {
418 anim.signal.clear_animation_target();
419 false
420 } else {
421 anim.next_tick = now + anim.frame_interval;
422 true
423 }
424 });
425
426 !self.animations.is_empty()
427 }
428
429 pub fn has_active(&self) -> bool {
432 !self.animations.is_empty()
433 }
434
435 pub fn has_running(&self) -> bool {
445 self.window_active && !self.animations.is_empty()
446 }
447
448 pub fn next_deadline(&self, arena: &WidgetArena, paint_epoch: u64) -> Option<Instant> {
454 if !self.window_active {
455 return None;
456 }
457 self.animations
458 .iter()
459 .filter(|anim| {
460 anim_widget_alive(arena, anim.widget_id)
461 && (!anim.looping || anim_widget_visible(arena, anim.widget_id, paint_epoch))
462 })
463 .map(|anim| anim.next_tick)
464 .min()
465 }
466
467 pub fn active_count(&self) -> usize {
469 self.animations.len()
470 }
471}
472
473use crate::motion_visibility::{
474 alive as anim_widget_alive, painted_recently as anim_widget_visible,
475};
476
477impl Default for AnimationScheduler {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483impl std::fmt::Debug for AnimationScheduler {
484 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 f.debug_struct("AnimationScheduler")
486 .field("active_count", &self.animations.len())
487 .field("window_active", &self.window_active)
488 .finish()
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::arena::WidgetArena;
496 use crate::test_widgets::FillWidget;
497
498 fn test_arena_with_widget() -> (WidgetArena, WidgetId) {
499 let mut arena = WidgetArena::new();
500 let id = arena.insert(Box::new(FillWidget::new()));
501 (arena, id)
502 }
503
504 #[test]
505 fn animate_from_current_to_target() {
506 let signal = Signal::<f32>::new_animated(0.0);
507 let mut scheduler = AnimationScheduler::new();
508 let (arena, id) = test_arena_with_widget();
509 let start = Instant::now();
510
511 scheduler.animate(
512 &signal,
513 id,
514 100.0,
515 Duration::from_millis(200),
516 Easing::Linear,
517 start,
518 );
519 assert_eq!(scheduler.active_count(), 1);
520
521 scheduler.tick(start, &arena, 0);
522 assert!((signal.get() - 0.0).abs() < 1.0);
523
524 let has_more = scheduler.tick(start + Duration::from_millis(100), &arena, 0);
525 assert!(has_more);
526 assert!((signal.get() - 50.0).abs() < 1.0);
527
528 let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
529 assert!(!has_more);
530 assert!((signal.get() - 100.0).abs() < 0.01);
531 assert_eq!(scheduler.active_count(), 0);
532 }
533
534 #[test]
535 fn eased_animation() {
536 let signal = Signal::<f32>::new_animated(0.0);
537 let mut scheduler = AnimationScheduler::new();
538 let (arena, id) = test_arena_with_widget();
539 let start = Instant::now();
540
541 scheduler.animate(
542 &signal,
543 id,
544 100.0,
545 Duration::from_millis(200),
546 Easing::EaseIn,
547 start,
548 );
549
550 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
551 assert!((signal.get() - 25.0).abs() < 1.0);
552 }
553
554 #[test]
555 fn zero_duration_sets_immediately() {
556 let signal = Signal::<f32>::new_animated(0.0);
557 let mut scheduler = AnimationScheduler::new();
558 let (_arena, id) = test_arena_with_widget();
559 let start = Instant::now();
560
561 scheduler.animate(&signal, id, 100.0, Duration::ZERO, Easing::Linear, start);
562 assert_eq!(scheduler.active_count(), 0);
563 assert!((signal.get() - 100.0).abs() < 0.01);
564 }
565
566 #[test]
567 fn replace_existing_animation() {
568 let signal = Signal::<f32>::new_animated(0.0);
569 let mut scheduler = AnimationScheduler::new();
570 let (arena, id) = test_arena_with_widget();
571 let start = Instant::now();
572
573 scheduler.animate(
574 &signal,
575 id,
576 100.0,
577 Duration::from_millis(200),
578 Easing::Linear,
579 start,
580 );
581
582 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
583 let mid_value = signal.get();
584 assert!((mid_value - 50.0).abs() < 1.0);
585
586 let mid_time = start + Duration::from_millis(100);
587 scheduler.animate(
588 &signal,
589 id,
590 0.0,
591 Duration::from_millis(100),
592 Easing::Linear,
593 mid_time,
594 );
595 assert_eq!(scheduler.active_count(), 1);
596
597 scheduler.tick(mid_time + Duration::from_millis(50), &arena, 0);
598 assert!((signal.get() - 25.0).abs() < 2.0);
599 }
600
601 #[test]
602 fn cancel_stops_animation() {
603 let signal = Signal::<f32>::new_animated(0.0);
604 let mut scheduler = AnimationScheduler::new();
605 let (_arena, id) = test_arena_with_widget();
606 let start = Instant::now();
607
608 scheduler.animate(
609 &signal,
610 id,
611 100.0,
612 Duration::from_millis(200),
613 Easing::Linear,
614 start,
615 );
616 assert_eq!(scheduler.active_count(), 1);
617
618 scheduler.cancel(&signal);
619 assert_eq!(scheduler.active_count(), 0);
620 }
621
622 #[test]
623 fn already_at_target_no_animation() {
624 let signal = Signal::<f32>::new_animated(50.0);
625 let mut scheduler = AnimationScheduler::new();
626 let (_arena, id) = test_arena_with_widget();
627 let start = Instant::now();
628
629 scheduler.animate(
630 &signal,
631 id,
632 50.0,
633 Duration::from_millis(200),
634 Easing::Linear,
635 start,
636 );
637 assert_eq!(scheduler.active_count(), 0);
638 }
639
640 #[test]
641 fn multiple_signals_animated_independently() {
642 let a = Signal::<f32>::new_animated(0.0);
643 let b = Signal::<f32>::new_animated(100.0);
644 let mut scheduler = AnimationScheduler::new();
645 let (arena, id) = test_arena_with_widget();
646 let start = Instant::now();
647
648 scheduler.animate(
649 &a,
650 id,
651 100.0,
652 Duration::from_millis(200),
653 Easing::Linear,
654 start,
655 );
656 scheduler.animate(
657 &b,
658 id,
659 0.0,
660 Duration::from_millis(200),
661 Easing::Linear,
662 start,
663 );
664 assert_eq!(scheduler.active_count(), 2);
665
666 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
667 assert!((a.get() - 50.0).abs() < 1.0);
668 assert!((b.get() - 50.0).abs() < 1.0);
669
670 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
671 assert_eq!(scheduler.active_count(), 0);
672 }
673
674 #[test]
675 fn looping_animation_restarts() {
676 let signal = Signal::<f32>::new_animated(0.0);
677 let mut scheduler = AnimationScheduler::new();
678 let (arena, id) = test_arena_with_widget();
679 let start = Instant::now();
680
681 scheduler.animate_looping(
682 &signal,
683 id,
684 0.0,
685 100.0,
686 Duration::from_millis(200),
687 Easing::Linear,
688 None,
689 0.0,
690 None,
691 start,
692 );
693
694 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
695 assert!((signal.get() - 50.0).abs() < 1.0);
696
697 let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
698 assert!(has_more, "looping animation should keep running");
699 assert!(signal.get() < 5.0);
700
701 scheduler.tick(start + Duration::from_millis(300), &arena, 0);
702 assert!((signal.get() - 50.0).abs() < 5.0);
703 }
704
705 #[test]
706 fn looping_animation_cancelled() {
707 let signal = Signal::<f32>::new_animated(0.0);
708 let mut scheduler = AnimationScheduler::new();
709 let (_arena, id) = test_arena_with_widget();
710 let start = Instant::now();
711
712 scheduler.animate_looping(
713 &signal,
714 id,
715 0.0,
716 10.0,
717 Duration::from_millis(100),
718 Easing::Linear,
719 None,
720 0.0,
721 None,
722 start,
723 );
724 assert_eq!(scheduler.active_count(), 1);
725
726 scheduler.cancel(&signal);
727 assert_eq!(scheduler.active_count(), 0);
728 }
729
730 #[test]
731 fn cancel_by_widget_removes_all_animations_owned_by_widget() {
732 let a = Signal::<f32>::new_animated(0.0);
733 let b = Signal::<f32>::new_animated(0.0);
734 let c = Signal::<f32>::new_animated(0.0);
735 let mut scheduler = AnimationScheduler::new();
736 let mut arena = WidgetArena::new();
737 let id_x = arena.insert(Box::new(FillWidget::new()));
738 let id_y = arena.insert(Box::new(FillWidget::new()));
739 let now = Instant::now();
740
741 scheduler.animate(&a, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
742 scheduler.animate(&b, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
743 scheduler.animate(&c, id_y, 1.0, Duration::from_secs(1), Easing::Linear, now);
744 assert_eq!(scheduler.active_count(), 3);
745
746 scheduler.cancel_by_widget(id_x);
747 assert_eq!(scheduler.active_count(), 1);
748
749 let _ = arena;
751 }
752
753 #[test]
754 fn window_inactive_pauses_tick() {
755 let signal = Signal::<f32>::new_animated(0.0);
756 let mut scheduler = AnimationScheduler::new();
757 let (arena, id) = test_arena_with_widget();
758 let start = Instant::now();
759
760 scheduler.animate(
761 &signal,
762 id,
763 100.0,
764 Duration::from_millis(200),
765 Easing::Linear,
766 start,
767 );
768 scheduler.set_window_active(false, start);
769
770 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
771 assert!(
772 signal.get() < 1.0,
773 "paused scheduler must not advance the signal"
774 );
775 assert!(scheduler.next_deadline(&arena, 0).is_none());
776 }
777
778 #[test]
779 fn resume_rebases_phase_continuously() {
780 let signal = Signal::<f32>::new_animated(0.0);
781 let mut scheduler = AnimationScheduler::new();
782 let (arena, id) = test_arena_with_widget();
783 let start = Instant::now();
784
785 scheduler.animate(
786 &signal,
787 id,
788 100.0,
789 Duration::from_millis(200),
790 Easing::Linear,
791 start,
792 );
793
794 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
796 assert!((signal.get() - 50.0).abs() < 1.0);
797
798 scheduler.set_window_active(false, start + Duration::from_millis(100));
800
801 let resume_at = start + Duration::from_millis(100) + Duration::from_secs(10);
803 scheduler.set_window_active(true, resume_at);
804
805 scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
808 let after_resume = signal.get();
809 assert!(
810 (after_resume - 75.0).abs() < 2.0,
811 "expected phase-continuous resume ≈ 75, got {after_resume}"
812 );
813 }
814
815 #[test]
816 fn epsilon_skips_intermediate_sets_but_not_terminal() {
817 let signal = Signal::<f32>::new_animated(0.0);
818 let mut scheduler = AnimationScheduler::new();
819 let (arena, id) = test_arena_with_widget();
820 let start = Instant::now();
821
822 scheduler.animate_with_options(
824 &signal,
825 id,
826 100.0,
827 Duration::from_millis(200),
828 Easing::Linear,
829 None,
830 10.0,
831 None,
832 start,
833 );
834
835 scheduler.tick(start + Duration::from_millis(10), &arena, 0);
836 assert!(
837 signal.get() < 1.0,
838 "sub-ε tick should NOT call signal.set, signal.get() = {}",
839 signal.get()
840 );
841
842 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
844 assert!(
845 (signal.get() - 100.0).abs() < 0.01,
846 "terminal tick must bypass ε and land exactly on end"
847 );
848 }
849
850 #[test]
851 fn max_duration_snaps_to_start_and_drops() {
852 let signal = Signal::<f32>::new_animated(0.0);
853 let mut scheduler = AnimationScheduler::new();
854 let (arena, id) = test_arena_with_widget();
855 let start = Instant::now();
856
857 scheduler.animate_looping(
858 &signal,
859 id,
860 0.0,
861 100.0,
862 Duration::from_millis(200),
863 Easing::Linear,
864 None,
865 0.0,
866 Some(Duration::from_secs(1)),
867 start,
868 );
869
870 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
871 assert!(signal.get() > 0.0);
872
873 let has_more = scheduler.tick(start + Duration::from_millis(1500), &arena, 0);
875 assert!(!has_more, "capped animation should drop");
876 assert_eq!(scheduler.active_count(), 0);
877 assert!(
878 signal.get().abs() < 0.01,
879 "capped animation should snap to start_value (0), got {}",
880 signal.get()
881 );
882 }
883
884 #[test]
885 fn one_shot_tween_runs_even_when_owner_appears_offscreen() {
886 let signal = Signal::<f32>::new_animated(0.0);
894 let mut scheduler = AnimationScheduler::new();
895 let (mut arena, id) = test_arena_with_widget();
896 let _ = arena.get_mut(id); let start = Instant::now();
900 let paint_epoch: u64 = 42; scheduler.animate(
903 &signal,
904 id,
905 100.0,
906 Duration::from_millis(200),
907 Easing::Linear,
908 start,
909 );
910
911 scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
912 assert!(
913 signal.get() > 10.0,
914 "one-shot tween must progress despite owner having stale paint_epoch (got {})",
915 signal.get()
916 );
917
918 scheduler.tick(start + Duration::from_millis(200), &arena, paint_epoch);
919 assert!(
920 (signal.get() - 100.0).abs() < 0.01,
921 "one-shot tween must reach target (got {})",
922 signal.get()
923 );
924 }
925
926 #[test]
927 fn looping_animation_pauses_when_owner_offscreen() {
928 let signal = Signal::<f32>::new_animated(0.0);
932 let mut scheduler = AnimationScheduler::new();
933 let (arena, id) = test_arena_with_widget();
934 let start = Instant::now();
935 let paint_epoch: u64 = 42;
936
937 scheduler.animate_looping(
938 &signal,
939 id,
940 0.0,
941 100.0,
942 Duration::from_millis(200),
943 Easing::Linear,
944 None,
945 0.0,
946 None,
947 start,
948 );
949
950 scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
953 assert!(
954 signal.get().abs() < 0.01,
955 "looping animation should pause for offscreen owner (got {})",
956 signal.get()
957 );
958 }
959}