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);
103
104pub struct AnimationScheduler {
106 animations: Vec<ActiveAnimation>,
107 window_active: bool,
112 paused_at: Option<Instant>,
116}
117
118impl AnimationScheduler {
119 pub fn new() -> Self {
120 Self {
121 animations: Vec::new(),
122 window_active: true,
123 paused_at: None,
124 }
125 }
126
127 pub fn animate(
131 &mut self,
132 signal: &Signal<f32>,
133 widget_id: WidgetId,
134 target: f32,
135 duration: Duration,
136 easing: Easing,
137 now: Instant,
138 ) {
139 self.animate_with_options(
140 signal, widget_id, target, duration, easing, None, 0.0, None, now,
141 );
142 }
143
144 #[allow(clippy::too_many_arguments)]
145 pub fn animate_with_options(
146 &mut self,
147 signal: &Signal<f32>,
148 widget_id: WidgetId,
149 target: f32,
150 duration: Duration,
151 easing: Easing,
152 frame_interval: Option<Duration>,
153 epsilon: f32,
154 max_duration: Option<Duration>,
155 now: Instant,
156 ) {
157 if let Some(existing) = self
167 .animations
168 .iter()
169 .find(|a| Signal::same(&a.signal, signal))
170 && !existing.looping
171 {
172 let elapsed = now.saturating_duration_since(existing.start_time);
173 let t = if existing.duration.is_zero() {
174 1.0
175 } else {
176 (elapsed.as_secs_f32() / existing.duration.as_secs_f32()).min(1.0)
177 };
178 let eased = existing.easing.apply(t);
179 let value = teksilo_tokens::lerp(existing.start_value, existing.end_value, eased);
180 signal.set(value);
181 }
182
183 let current = signal.get();
184 self.cancel(signal);
185
186 if (current - target).abs() < f32::EPSILON || duration.is_zero() {
187 signal.set(target);
188 signal.clear_animation_target();
189 return;
190 }
191
192 self.animations.push(ActiveAnimation {
193 widget_id,
194 signal: signal.clone(),
195 start_value: current,
196 end_value: target,
197 start_time: now,
198 duration,
199 easing,
200 frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
201 next_tick: now,
202 looping: false,
203 epsilon,
204 last_set_value: current,
205 started_at: now,
206 max_duration,
207 });
208 }
209
210 #[allow(clippy::too_many_arguments)]
214 pub fn animate_looping(
215 &mut self,
216 signal: &Signal<f32>,
217 widget_id: WidgetId,
218 start: f32,
219 end: f32,
220 period: Duration,
221 easing: Easing,
222 frame_interval: Option<Duration>,
223 epsilon: f32,
224 max_duration: Option<Duration>,
225 now: Instant,
226 ) {
227 self.cancel(signal);
228 signal.set(start);
229
230 self.animations.push(ActiveAnimation {
231 widget_id,
232 signal: signal.clone(),
233 start_value: start,
234 end_value: end,
235 start_time: now,
236 duration: period,
237 easing,
238 frame_interval: frame_interval.unwrap_or(DEFAULT_FRAME_INTERVAL),
239 next_tick: now,
240 looping: true,
241 epsilon,
242 last_set_value: start,
243 started_at: now,
244 max_duration,
245 });
246 }
247
248 pub fn cancel(&mut self, signal: &Signal<f32>) {
250 self.animations.retain(|a| !Signal::same(&a.signal, signal));
251 }
252
253 pub fn cancel_by_widget(&mut self, widget_id: WidgetId) {
261 self.animations.retain(|a| {
262 if a.widget_id == widget_id {
263 a.signal.clear_animation_target();
264 false
265 } else {
266 true
267 }
268 });
269 }
270
271 pub fn set_window_active(&mut self, active: bool, now: Instant) {
279 if self.window_active == active {
280 return;
281 }
282 if active {
283 if let Some(paused_at) = self.paused_at.take() {
284 let offset = now.saturating_duration_since(paused_at);
285 for anim in &mut self.animations {
286 anim.start_time += offset;
287 anim.next_tick = now;
288 }
289 }
290 } else {
291 self.paused_at = Some(now);
292 }
293 self.window_active = active;
294 }
295
296 pub fn is_window_active(&self) -> bool {
297 self.window_active
298 }
299
300 pub fn tick(&mut self, now: Instant, arena: &WidgetArena, paint_epoch: u64) -> bool {
314 if !self.window_active {
315 return !self.animations.is_empty();
316 }
317
318 self.animations.retain_mut(|anim| {
319 if !anim_widget_alive(arena, anim.widget_id) {
320 anim.signal.clear_animation_target();
321 return false;
322 }
323
324 if let Some(max) = anim.max_duration
325 && now.saturating_duration_since(anim.started_at) >= max
326 {
327 anim.signal.set(anim.start_value);
328 anim.signal.clear_animation_target();
329 return false;
330 }
331
332 if anim.looping && !anim_widget_visible(arena, anim.widget_id, paint_epoch) {
333 anim.next_tick = now + anim.frame_interval;
339 return true;
340 }
341
342 if now < anim.next_tick {
343 return true;
344 }
345
346 let elapsed = now.saturating_duration_since(anim.start_time);
347 let t = if anim.duration.is_zero() {
348 1.0
349 } else {
350 (elapsed.as_secs_f32() / anim.duration.as_secs_f32()).min(1.0)
351 };
352 let eased = anim.easing.apply(t);
353 let value = teksilo_tokens::lerp(anim.start_value, anim.end_value, eased);
354
355 let terminal = t >= 1.0;
356 if terminal || (value - anim.last_set_value).abs() >= anim.epsilon {
360 anim.signal.set(value);
361 anim.last_set_value = value;
362 }
363
364 if t >= 1.0 && anim.looping {
365 anim.start_time = now;
366 anim.signal.set(anim.start_value);
367 anim.last_set_value = anim.start_value;
368 anim.next_tick = now + anim.frame_interval;
369 true
370 } else if t >= 1.0 {
371 anim.signal.clear_animation_target();
372 false
373 } else {
374 anim.next_tick = now + anim.frame_interval;
375 true
376 }
377 });
378
379 !self.animations.is_empty()
380 }
381
382 pub fn has_active(&self) -> bool {
385 !self.animations.is_empty()
386 }
387
388 pub fn has_running(&self) -> bool {
398 self.window_active && !self.animations.is_empty()
399 }
400
401 pub fn next_deadline(&self, arena: &WidgetArena, paint_epoch: u64) -> Option<Instant> {
407 if !self.window_active {
408 return None;
409 }
410 self.animations
411 .iter()
412 .filter(|anim| {
413 anim_widget_alive(arena, anim.widget_id)
414 && (!anim.looping || anim_widget_visible(arena, anim.widget_id, paint_epoch))
415 })
416 .map(|anim| anim.next_tick)
417 .min()
418 }
419
420 pub fn active_count(&self) -> usize {
422 self.animations.len()
423 }
424}
425
426use crate::motion_visibility::{
427 alive as anim_widget_alive, painted_recently as anim_widget_visible,
428};
429
430impl Default for AnimationScheduler {
431 fn default() -> Self {
432 Self::new()
433 }
434}
435
436impl std::fmt::Debug for AnimationScheduler {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 f.debug_struct("AnimationScheduler")
439 .field("active_count", &self.animations.len())
440 .field("window_active", &self.window_active)
441 .finish()
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use crate::arena::WidgetArena;
449 use crate::test_widgets::FillWidget;
450
451 fn test_arena_with_widget() -> (WidgetArena, WidgetId) {
452 let mut arena = WidgetArena::new();
453 let id = arena.insert(Box::new(FillWidget::new()));
454 (arena, id)
455 }
456
457 #[test]
458 fn animate_from_current_to_target() {
459 let signal = Signal::<f32>::new_animated(0.0);
460 let mut scheduler = AnimationScheduler::new();
461 let (arena, id) = test_arena_with_widget();
462 let start = Instant::now();
463
464 scheduler.animate(
465 &signal,
466 id,
467 100.0,
468 Duration::from_millis(200),
469 Easing::Linear,
470 start,
471 );
472 assert_eq!(scheduler.active_count(), 1);
473
474 scheduler.tick(start, &arena, 0);
475 assert!((signal.get() - 0.0).abs() < 1.0);
476
477 let has_more = scheduler.tick(start + Duration::from_millis(100), &arena, 0);
478 assert!(has_more);
479 assert!((signal.get() - 50.0).abs() < 1.0);
480
481 let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
482 assert!(!has_more);
483 assert!((signal.get() - 100.0).abs() < 0.01);
484 assert_eq!(scheduler.active_count(), 0);
485 }
486
487 #[test]
488 fn eased_animation() {
489 let signal = Signal::<f32>::new_animated(0.0);
490 let mut scheduler = AnimationScheduler::new();
491 let (arena, id) = test_arena_with_widget();
492 let start = Instant::now();
493
494 scheduler.animate(
495 &signal,
496 id,
497 100.0,
498 Duration::from_millis(200),
499 Easing::EaseIn,
500 start,
501 );
502
503 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
504 assert!((signal.get() - 25.0).abs() < 1.0);
505 }
506
507 #[test]
508 fn zero_duration_sets_immediately() {
509 let signal = Signal::<f32>::new_animated(0.0);
510 let mut scheduler = AnimationScheduler::new();
511 let (_arena, id) = test_arena_with_widget();
512 let start = Instant::now();
513
514 scheduler.animate(&signal, id, 100.0, Duration::ZERO, Easing::Linear, start);
515 assert_eq!(scheduler.active_count(), 0);
516 assert!((signal.get() - 100.0).abs() < 0.01);
517 }
518
519 #[test]
520 fn replace_existing_animation() {
521 let signal = Signal::<f32>::new_animated(0.0);
522 let mut scheduler = AnimationScheduler::new();
523 let (arena, id) = test_arena_with_widget();
524 let start = Instant::now();
525
526 scheduler.animate(
527 &signal,
528 id,
529 100.0,
530 Duration::from_millis(200),
531 Easing::Linear,
532 start,
533 );
534
535 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
536 let mid_value = signal.get();
537 assert!((mid_value - 50.0).abs() < 1.0);
538
539 let mid_time = start + Duration::from_millis(100);
540 scheduler.animate(
541 &signal,
542 id,
543 0.0,
544 Duration::from_millis(100),
545 Easing::Linear,
546 mid_time,
547 );
548 assert_eq!(scheduler.active_count(), 1);
549
550 scheduler.tick(mid_time + Duration::from_millis(50), &arena, 0);
551 assert!((signal.get() - 25.0).abs() < 2.0);
552 }
553
554 #[test]
555 fn cancel_stops_animation() {
556 let signal = Signal::<f32>::new_animated(0.0);
557 let mut scheduler = AnimationScheduler::new();
558 let (_arena, id) = test_arena_with_widget();
559 let start = Instant::now();
560
561 scheduler.animate(
562 &signal,
563 id,
564 100.0,
565 Duration::from_millis(200),
566 Easing::Linear,
567 start,
568 );
569 assert_eq!(scheduler.active_count(), 1);
570
571 scheduler.cancel(&signal);
572 assert_eq!(scheduler.active_count(), 0);
573 }
574
575 #[test]
576 fn already_at_target_no_animation() {
577 let signal = Signal::<f32>::new_animated(50.0);
578 let mut scheduler = AnimationScheduler::new();
579 let (_arena, id) = test_arena_with_widget();
580 let start = Instant::now();
581
582 scheduler.animate(
583 &signal,
584 id,
585 50.0,
586 Duration::from_millis(200),
587 Easing::Linear,
588 start,
589 );
590 assert_eq!(scheduler.active_count(), 0);
591 }
592
593 #[test]
594 fn multiple_signals_animated_independently() {
595 let a = Signal::<f32>::new_animated(0.0);
596 let b = Signal::<f32>::new_animated(100.0);
597 let mut scheduler = AnimationScheduler::new();
598 let (arena, id) = test_arena_with_widget();
599 let start = Instant::now();
600
601 scheduler.animate(
602 &a,
603 id,
604 100.0,
605 Duration::from_millis(200),
606 Easing::Linear,
607 start,
608 );
609 scheduler.animate(
610 &b,
611 id,
612 0.0,
613 Duration::from_millis(200),
614 Easing::Linear,
615 start,
616 );
617 assert_eq!(scheduler.active_count(), 2);
618
619 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
620 assert!((a.get() - 50.0).abs() < 1.0);
621 assert!((b.get() - 50.0).abs() < 1.0);
622
623 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
624 assert_eq!(scheduler.active_count(), 0);
625 }
626
627 #[test]
628 fn looping_animation_restarts() {
629 let signal = Signal::<f32>::new_animated(0.0);
630 let mut scheduler = AnimationScheduler::new();
631 let (arena, id) = test_arena_with_widget();
632 let start = Instant::now();
633
634 scheduler.animate_looping(
635 &signal,
636 id,
637 0.0,
638 100.0,
639 Duration::from_millis(200),
640 Easing::Linear,
641 None,
642 0.0,
643 None,
644 start,
645 );
646
647 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
648 assert!((signal.get() - 50.0).abs() < 1.0);
649
650 let has_more = scheduler.tick(start + Duration::from_millis(200), &arena, 0);
651 assert!(has_more, "looping animation should keep running");
652 assert!(signal.get() < 5.0);
653
654 scheduler.tick(start + Duration::from_millis(300), &arena, 0);
655 assert!((signal.get() - 50.0).abs() < 5.0);
656 }
657
658 #[test]
659 fn looping_animation_cancelled() {
660 let signal = Signal::<f32>::new_animated(0.0);
661 let mut scheduler = AnimationScheduler::new();
662 let (_arena, id) = test_arena_with_widget();
663 let start = Instant::now();
664
665 scheduler.animate_looping(
666 &signal,
667 id,
668 0.0,
669 10.0,
670 Duration::from_millis(100),
671 Easing::Linear,
672 None,
673 0.0,
674 None,
675 start,
676 );
677 assert_eq!(scheduler.active_count(), 1);
678
679 scheduler.cancel(&signal);
680 assert_eq!(scheduler.active_count(), 0);
681 }
682
683 #[test]
684 fn cancel_by_widget_removes_all_animations_owned_by_widget() {
685 let a = Signal::<f32>::new_animated(0.0);
686 let b = Signal::<f32>::new_animated(0.0);
687 let c = Signal::<f32>::new_animated(0.0);
688 let mut scheduler = AnimationScheduler::new();
689 let mut arena = WidgetArena::new();
690 let id_x = arena.insert(Box::new(FillWidget::new()));
691 let id_y = arena.insert(Box::new(FillWidget::new()));
692 let now = Instant::now();
693
694 scheduler.animate(&a, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
695 scheduler.animate(&b, id_x, 1.0, Duration::from_secs(1), Easing::Linear, now);
696 scheduler.animate(&c, id_y, 1.0, Duration::from_secs(1), Easing::Linear, now);
697 assert_eq!(scheduler.active_count(), 3);
698
699 scheduler.cancel_by_widget(id_x);
700 assert_eq!(scheduler.active_count(), 1);
701
702 let _ = arena;
704 }
705
706 #[test]
707 fn window_inactive_pauses_tick() {
708 let signal = Signal::<f32>::new_animated(0.0);
709 let mut scheduler = AnimationScheduler::new();
710 let (arena, id) = test_arena_with_widget();
711 let start = Instant::now();
712
713 scheduler.animate(
714 &signal,
715 id,
716 100.0,
717 Duration::from_millis(200),
718 Easing::Linear,
719 start,
720 );
721 scheduler.set_window_active(false, start);
722
723 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
724 assert!(
725 signal.get() < 1.0,
726 "paused scheduler must not advance the signal"
727 );
728 assert!(scheduler.next_deadline(&arena, 0).is_none());
729 }
730
731 #[test]
732 fn resume_rebases_phase_continuously() {
733 let signal = Signal::<f32>::new_animated(0.0);
734 let mut scheduler = AnimationScheduler::new();
735 let (arena, id) = test_arena_with_widget();
736 let start = Instant::now();
737
738 scheduler.animate(
739 &signal,
740 id,
741 100.0,
742 Duration::from_millis(200),
743 Easing::Linear,
744 start,
745 );
746
747 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
749 assert!((signal.get() - 50.0).abs() < 1.0);
750
751 scheduler.set_window_active(false, start + Duration::from_millis(100));
753
754 let resume_at = start + Duration::from_millis(100) + Duration::from_secs(10);
756 scheduler.set_window_active(true, resume_at);
757
758 scheduler.tick(resume_at + Duration::from_millis(50), &arena, 0);
761 let after_resume = signal.get();
762 assert!(
763 (after_resume - 75.0).abs() < 2.0,
764 "expected phase-continuous resume ≈ 75, got {after_resume}"
765 );
766 }
767
768 #[test]
769 fn epsilon_skips_intermediate_sets_but_not_terminal() {
770 let signal = Signal::<f32>::new_animated(0.0);
771 let mut scheduler = AnimationScheduler::new();
772 let (arena, id) = test_arena_with_widget();
773 let start = Instant::now();
774
775 scheduler.animate_with_options(
777 &signal,
778 id,
779 100.0,
780 Duration::from_millis(200),
781 Easing::Linear,
782 None,
783 10.0,
784 None,
785 start,
786 );
787
788 scheduler.tick(start + Duration::from_millis(10), &arena, 0);
789 assert!(
790 signal.get() < 1.0,
791 "sub-ε tick should NOT call signal.set, signal.get() = {}",
792 signal.get()
793 );
794
795 scheduler.tick(start + Duration::from_millis(200), &arena, 0);
797 assert!(
798 (signal.get() - 100.0).abs() < 0.01,
799 "terminal tick must bypass ε and land exactly on end"
800 );
801 }
802
803 #[test]
804 fn max_duration_snaps_to_start_and_drops() {
805 let signal = Signal::<f32>::new_animated(0.0);
806 let mut scheduler = AnimationScheduler::new();
807 let (arena, id) = test_arena_with_widget();
808 let start = Instant::now();
809
810 scheduler.animate_looping(
811 &signal,
812 id,
813 0.0,
814 100.0,
815 Duration::from_millis(200),
816 Easing::Linear,
817 None,
818 0.0,
819 Some(Duration::from_secs(1)),
820 start,
821 );
822
823 scheduler.tick(start + Duration::from_millis(100), &arena, 0);
824 assert!(signal.get() > 0.0);
825
826 let has_more = scheduler.tick(start + Duration::from_millis(1500), &arena, 0);
828 assert!(!has_more, "capped animation should drop");
829 assert_eq!(scheduler.active_count(), 0);
830 assert!(
831 signal.get().abs() < 0.01,
832 "capped animation should snap to start_value (0), got {}",
833 signal.get()
834 );
835 }
836
837 #[test]
838 fn one_shot_tween_runs_even_when_owner_appears_offscreen() {
839 let signal = Signal::<f32>::new_animated(0.0);
847 let mut scheduler = AnimationScheduler::new();
848 let (mut arena, id) = test_arena_with_widget();
849 let _ = arena.get_mut(id); let start = Instant::now();
853 let paint_epoch: u64 = 42; scheduler.animate(
856 &signal,
857 id,
858 100.0,
859 Duration::from_millis(200),
860 Easing::Linear,
861 start,
862 );
863
864 scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
865 assert!(
866 signal.get() > 10.0,
867 "one-shot tween must progress despite owner having stale paint_epoch (got {})",
868 signal.get()
869 );
870
871 scheduler.tick(start + Duration::from_millis(200), &arena, paint_epoch);
872 assert!(
873 (signal.get() - 100.0).abs() < 0.01,
874 "one-shot tween must reach target (got {})",
875 signal.get()
876 );
877 }
878
879 #[test]
880 fn looping_animation_pauses_when_owner_offscreen() {
881 let signal = Signal::<f32>::new_animated(0.0);
885 let mut scheduler = AnimationScheduler::new();
886 let (arena, id) = test_arena_with_widget();
887 let start = Instant::now();
888 let paint_epoch: u64 = 42;
889
890 scheduler.animate_looping(
891 &signal,
892 id,
893 0.0,
894 100.0,
895 Duration::from_millis(200),
896 Easing::Linear,
897 None,
898 0.0,
899 None,
900 start,
901 );
902
903 scheduler.tick(start + Duration::from_millis(100), &arena, paint_epoch);
906 assert!(
907 signal.get().abs() < 0.01,
908 "looping animation should pause for offscreen owner (got {})",
909 signal.get()
910 );
911 }
912}