Skip to main content

aura_anim_core/
runtime.rs

1//! Runtime storage, typed handles, and animation commands.
2
3use std::marker::PhantomData;
4
5use crate::{
6    runtime::{anim_dyn::AnimationDyn, typed::TypedAnimation},
7    timing::{Duration, Timing},
8    traits::{Animatable, Animation, AnimationState, IntoMotionAnimation},
9    tween::Tween,
10};
11
12mod anim_dyn;
13mod command;
14mod error;
15mod event;
16mod motion;
17mod typed;
18
19pub use command::AnimationCommand;
20pub use error::MotionError;
21pub use event::{
22    InterruptionReason, MotionEvent, MotionEventKind, MotionEventTarget, MotionId, PlaybackId,
23    RemovalReason,
24};
25pub use motion::Motion;
26
27/// Controls whether the runtime retains an animation after it settles.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum RetainPolicy {
30    /// Keep the animation and its final value until explicitly removed.
31    #[default]
32    Keep,
33    /// Remove the animation after it completes or is canceled.
34    DropWhenSettled,
35}
36
37struct AnimationSlot {
38    generation: u64,
39    playback_revision: u64,
40    terminal_emitted: bool,
41    transition: Timing,
42    retain_policy: RetainPolicy,
43    animation: Option<Box<dyn AnimationDyn>>,
44    active: bool,
45    queued: bool,
46}
47
48/// Stores animations, advances active values, and queues lifecycle events.
49///
50/// # Examples
51///
52/// ```
53/// use aura_anim_core::{MotionRuntime, timing::Timing};
54/// use std::time::Duration;
55///
56/// let mut runtime = MotionRuntime::new();
57/// let opacity = runtime.motion_with(0.0_f32, Timing::new(100.0));
58///
59/// opacity.transition_to(1.0, &mut runtime).unwrap();
60/// runtime.tick(Duration::from_millis(50));
61///
62/// assert_eq!(opacity.value(&runtime).unwrap(), 0.5);
63/// assert!(opacity.is_active(&runtime).unwrap());
64/// assert!(runtime.events().is_empty());
65/// ```
66#[derive(Default)]
67pub struct MotionRuntime {
68    slots: Vec<AnimationSlot>,
69    free: Vec<usize>,
70    active: Vec<MotionId>,
71    next_active: Vec<MotionId>,
72    events: Vec<MotionEvent>,
73    active_count: usize,
74    motion_count: usize,
75    last_tick: Option<std::time::Instant>,
76}
77
78impl MotionRuntime {
79    /// Creates an empty motion runtime.
80    #[must_use]
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Inserts an idle motion with the default transition timing.
86    pub fn motion<T: Animatable>(&mut self, initial: T) -> Motion<T> {
87        self.motion_with(initial, Timing::new(200.0))
88    }
89
90    /// Inserts an idle motion with the provided transition timing.
91    pub fn motion_with<T: Animatable>(&mut self, initial: T, timing: Timing) -> Motion<T> {
92        self.insert(Tween::with_timing(initial, timing), timing)
93    }
94
95    /// Inserts an animation that remains stored after it settles.
96    pub fn insert<T: Animatable>(
97        &mut self,
98        animation: impl Animation<T>,
99        transition: Timing,
100    ) -> Motion<T> {
101        self.insert_with_policy(animation, transition, RetainPolicy::Keep)
102    }
103
104    /// Inserts an animation that is removed after it settles.
105    pub fn play_once<T: Animatable>(&mut self, animation: impl Animation<T>) -> Motion<T> {
106        self.insert_with_policy(animation, Timing::default(), RetainPolicy::DropWhenSettled)
107    }
108
109    /// Inserts an animation with transition timing and a retention policy.
110    pub fn insert_with_policy<T: Animatable>(
111        &mut self,
112        animation: impl Animation<T>,
113        transition: Timing,
114        retain_policy: RetainPolicy,
115    ) -> Motion<T> {
116        let mut animation = TypedAnimation::new(animation);
117        animation.compact();
118        let animation: Box<dyn AnimationDyn> = Box::new(animation);
119        let (id, reused_slot) = if let Some(slot_index) = self.free.pop() {
120            let slot = &mut self.slots[slot_index];
121            slot.generation = slot.generation.wrapping_add(1);
122            slot.playback_revision = 0;
123            slot.terminal_emitted = false;
124            slot.transition = transition;
125            slot.retain_policy = retain_policy;
126            slot.animation = Some(animation);
127            slot.active = false;
128            slot.queued = false;
129            (MotionId::new(slot_index, slot.generation), true)
130        } else {
131            let slot = self.slots.len();
132            self.slots.push(AnimationSlot {
133                generation: 0,
134                playback_revision: 0,
135                terminal_emitted: false,
136                transition,
137                retain_policy,
138                animation: Some(animation),
139                active: false,
140                queued: false,
141            });
142            (MotionId::new(slot, 0), false)
143        };
144
145        self.motion_count += 1;
146        #[cfg(feature = "tracing")]
147        tracing::debug!(
148            target: "aura_anim::runtime",
149            slot = id.slot(),
150            generation = id.generation(),
151            value_type = std::any::type_name::<T>(),
152            ?retain_policy,
153            reused_slot,
154            motion_count = self.motion_count,
155            "inserted motion"
156        );
157        #[cfg(not(feature = "tracing"))]
158        let _ = reused_slot;
159        let motion = Motion::new(id, PhantomData);
160        self.sync_slot_after_change(id);
161        motion
162    }
163
164    /// Advances every active animation by `delta`.
165    pub fn tick(&mut self, delta: impl Into<Duration>) {
166        let delta = delta.into();
167        #[cfg(feature = "tracing")]
168        tracing::trace!(
169            target: "aura_anim::runtime",
170            delta_ms = delta.as_millis(),
171            active_count = self.active_count,
172            queued_count = self.active.len(),
173            "ticking motions"
174        );
175        self.next_active.clear();
176
177        for index in 0..self.active.len() {
178            let id = self.active[index];
179            let settled = {
180                let Some(slot) = self.slots.get_mut(id.slot()) else {
181                    continue;
182                };
183                if slot.generation != id.generation() {
184                    continue;
185                }
186                slot.queued = false;
187                if !slot.active {
188                    continue;
189                }
190                let Some(animation) = slot.animation.as_mut() else {
191                    continue;
192                };
193
194                animation.advance(delta);
195                if animation.is_active() {
196                    self.next_active.push(id);
197                    slot.queued = true;
198                    None
199                } else {
200                    slot.active = false;
201                    Some((animation.state(), slot.retain_policy))
202                }
203            };
204
205            let Some((state, retain_policy)) = settled else {
206                continue;
207            };
208            self.active_count = self.active_count.saturating_sub(1);
209            self.record_terminal(id, state);
210
211            if retain_policy == RetainPolicy::DropWhenSettled
212                && matches!(state, AnimationState::Completed | AnimationState::Canceled)
213            {
214                #[cfg(feature = "tracing")]
215                tracing::debug!(
216                    target: "aura_anim::runtime",
217                    slot = id.slot(),
218                    generation = id.generation(),
219                    ?state,
220                    "dropping settled transient motion"
221                );
222                self.remove_slot(id, RemovalReason::Settled);
223                continue;
224            }
225
226            #[cfg(feature = "tracing")]
227            tracing::debug!(
228                target: "aura_anim::runtime",
229                slot = id.slot(),
230                generation = id.generation(),
231                ?state,
232                "motion settled"
233            );
234            if let Ok(animation) = self.animation_mut(id) {
235                animation.compact();
236            }
237        }
238
239        std::mem::swap(&mut self.active, &mut self.next_active);
240        if self.active_count == 0 {
241            self.active.clear();
242            self.next_active.clear();
243            self.last_tick = None;
244        }
245        #[cfg(feature = "tracing")]
246        tracing::trace!(
247            target: "aura_anim::runtime",
248            active_count = self.active_count,
249            motion_count = self.motion_count,
250            "finished ticking motions"
251        );
252    }
253
254    /// Advances active animations using elapsed wall-clock time.
255    ///
256    /// The first call establishes the clock origin and advances by zero.
257    pub fn tick_at(&mut self, now: std::time::Instant) {
258        let delta = self.last_tick.map_or(std::time::Duration::ZERO, |last| {
259            now.saturating_duration_since(last)
260        });
261        self.last_tick = Some(now);
262        self.tick(delta);
263    }
264
265    /// Returns queued lifecycle events without consuming them.
266    #[must_use]
267    pub fn events(&self) -> &[MotionEvent] {
268        &self.events
269    }
270
271    /// Takes all queued lifecycle events.
272    ///
273    /// Events remain queued until they are taken or explicitly cleared.
274    pub fn take_events(&mut self) -> Vec<MotionEvent> {
275        std::mem::take(&mut self.events)
276    }
277
278    /// Removes all queued lifecycle events.
279    pub fn clear_events(&mut self) {
280        self.events.clear();
281    }
282
283    /// Returns the number of queued lifecycle events.
284    #[must_use]
285    pub fn pending_event_count(&self) -> usize {
286        self.events.len()
287    }
288
289    /// Returns the number of animations currently marked active.
290    #[must_use]
291    pub fn active_count(&self) -> usize {
292        self.active_count
293    }
294
295    /// Returns whether at least one animation is active.
296    #[must_use]
297    pub fn has_active(&self) -> bool {
298        self.active_count > 0
299    }
300
301    /// Returns the number of stored animations.
302    #[must_use]
303    pub fn motion_count(&self) -> usize {
304        self.motion_count
305    }
306
307    /// Returns the allocated capacity of the runtime slot storage.
308    #[must_use]
309    pub fn slot_capacity(&self) -> usize {
310        self.slots.capacity()
311    }
312
313    /// Releases unused slot and queue capacity.
314    pub fn shrink_to_fit(&mut self) {
315        while self
316            .slots
317            .last()
318            .is_some_and(|slot| slot.animation.is_none())
319        {
320            self.slots.pop();
321        }
322        self.free.retain(|slot| *slot < self.slots.len());
323        self.slots.shrink_to_fit();
324        self.free.shrink_to_fit();
325        self.active.shrink_to_fit();
326        self.next_active.shrink_to_fit();
327        self.events.shrink_to_fit();
328    }
329
330    /// Returns the current value for `motion`.
331    pub fn value<T: Animatable>(&self, motion: Motion<T>) -> Result<&T, MotionError> {
332        let animation = self.animation(motion.id())?;
333        animation.value_any().downcast_ref::<T>().ok_or_else(|| {
334            let error = MotionError::TypeMismatch {
335                expected: std::any::type_name::<T>(),
336                actual: animation.value_type_name(),
337            };
338            #[cfg(feature = "tracing")]
339            trace_motion_error(motion.id(), &error);
340            error
341        })
342    }
343
344    /// Returns the lifecycle state for `motion`.
345    pub fn state<T: Animatable>(&self, motion: Motion<T>) -> Result<AnimationState, MotionError> {
346        self.animation(motion.id()).map(AnimationDyn::state)
347    }
348
349    /// Returns the ID of the motion's current playback.
350    pub fn playback<T: Animatable>(&self, motion: Motion<T>) -> Result<PlaybackId, MotionError> {
351        self.animation(motion.id())?;
352        Ok(self.current_playback(motion.id()))
353    }
354
355    /// Returns whether `motion` is active.
356    pub fn is_active<T: Animatable>(&self, motion: Motion<T>) -> Result<bool, MotionError> {
357        self.animation(motion.id()).map(AnimationDyn::is_active)
358    }
359
360    /// Transitions `motion` toward `target`.
361    pub fn transition_to<T: Animatable>(
362        &mut self,
363        motion: Motion<T>,
364        target: T,
365    ) -> Result<(), MotionError> {
366        self.transition_to_tracked(motion, target).map(|_| ())
367    }
368
369    /// Transitions `motion` toward `target` and returns its new playback ID.
370    pub fn transition_to_tracked<T: Animatable>(
371        &mut self,
372        motion: Motion<T>,
373        target: T,
374    ) -> Result<PlaybackId, MotionError> {
375        let previous_state = self.state(motion)?;
376        if self.animation_mut(motion.id())?.retarget_any(&target) {
377            self.record_interruption(motion.id(), previous_state, InterruptionReason::Retargeted);
378            let playback = self.start_playback(motion.id());
379            #[cfg(feature = "tracing")]
380            tracing::debug!(
381                target: "aura_anim::runtime",
382                slot = motion.id().slot(),
383                generation = motion.id().generation(),
384                value_type = std::any::type_name::<T>(),
385                "retargeted motion in place"
386            );
387            self.sync_slot_after_change(motion.id());
388            return Ok(playback);
389        }
390
391        let current = self.value(motion)?.clone();
392        let transition = self.slots[motion.id().slot()].transition;
393        #[cfg(feature = "tracing")]
394        tracing::debug!(
395            target: "aura_anim::runtime",
396            slot = motion.id().slot(),
397            generation = motion.id().generation(),
398            value_type = std::any::type_name::<T>(),
399            "replacing motion with transition tween"
400        );
401        self.replace(
402            motion.id(),
403            TypedAnimation::new(Tween::between(current, target, transition)),
404            InterruptionReason::Retargeted,
405        )
406    }
407
408    /// Replaces the animation associated with `motion`.
409    pub fn play<T, P, Kind>(&mut self, motion: Motion<T>, playback: P) -> Result<(), MotionError>
410    where
411        T: Animatable,
412        P: IntoMotionAnimation<T, Kind>,
413    {
414        self.play_tracked(motion, playback).map(|_| ())
415    }
416
417    /// Replaces the animation associated with `motion` and returns its playback ID.
418    pub fn play_tracked<T, P, Kind>(
419        &mut self,
420        motion: Motion<T>,
421        playback: P,
422    ) -> Result<PlaybackId, MotionError>
423    where
424        T: Animatable,
425        P: IntoMotionAnimation<T, Kind>,
426    {
427        let animation = playback.into_motion_animation(self.value(motion)?);
428        #[cfg(feature = "tracing")]
429        tracing::debug!(
430            target: "aura_anim::runtime",
431            slot = motion.id().slot(),
432            generation = motion.id().generation(),
433            value_type = std::any::type_name::<T>(),
434            "playing replacement animation"
435        );
436        self.replace(
437            motion.id(),
438            TypedAnimation::new(animation),
439            InterruptionReason::Replaced,
440        )
441    }
442
443    /// Applies a command to `motion`.
444    pub fn command<T: Animatable>(
445        &mut self,
446        motion: Motion<T>,
447        command: AnimationCommand,
448    ) -> Result<(), MotionError> {
449        self.animation(motion.id())?;
450        self.apply_command(motion.id(), command);
451        Ok(())
452    }
453
454    /// Applies `command` to every stored motion.
455    ///
456    /// This includes idle, running, paused, and settled motions. Commands keep
457    /// their normal per-animation semantics, including terminal events and
458    /// [`RetainPolicy::DropWhenSettled`] removal.
459    pub fn command_all(&mut self, command: AnimationCommand) {
460        #[cfg(feature = "tracing")]
461        tracing::debug!(
462            target: "aura_anim::runtime",
463            ?command,
464            motion_count = self.motion_count,
465            "applying command to all motions"
466        );
467
468        for slot_index in 0..self.slots.len() {
469            let Some(slot) = self.slots.get(slot_index) else {
470                continue;
471            };
472            if slot.animation.is_none() {
473                continue;
474            }
475            let id = MotionId::new(slot_index, slot.generation);
476            self.apply_command(id, command);
477        }
478
479        #[cfg(feature = "tracing")]
480        tracing::debug!(
481            target: "aura_anim::runtime",
482            ?command,
483            active_count = self.active_count,
484            motion_count = self.motion_count,
485            "applied command to all motions"
486        );
487    }
488
489    fn apply_command(&mut self, id: MotionId, command: AnimationCommand) {
490        #[cfg(feature = "tracing")]
491        tracing::debug!(
492            target: "aura_anim::runtime",
493            slot = id.slot(),
494            generation = id.generation(),
495            ?command,
496            "applying motion command"
497        );
498        let (active, state) = {
499            let Some(slot) = self.slots.get_mut(id.slot()) else {
500                return;
501            };
502            if slot.generation != id.generation() {
503                return;
504            }
505            let Some(animation) = slot.animation.as_mut() else {
506                return;
507            };
508            animation.command(command);
509            (animation.is_active(), animation.state())
510        };
511
512        if active {
513            self.activate(id);
514        } else {
515            self.deactivate(id);
516            self.record_terminal(id, state);
517            if self.slots[id.slot()].retain_policy == RetainPolicy::DropWhenSettled
518                && matches!(state, AnimationState::Completed | AnimationState::Canceled)
519            {
520                self.remove_slot(id, RemovalReason::Settled);
521            } else {
522                let Some(animation) = self.slots[id.slot()].animation.as_mut() else {
523                    return;
524                };
525                animation.compact();
526            }
527        }
528        #[cfg(feature = "tracing")]
529        tracing::debug!(
530            target: "aura_anim::runtime",
531            slot = id.slot(),
532            generation = id.generation(),
533            ?state,
534            active,
535            "applied motion command"
536        );
537    }
538
539    /// Removes the animation referenced by `motion`.
540    pub fn remove<T: Animatable>(&mut self, motion: Motion<T>) -> Result<(), MotionError> {
541        let state = self.animation(motion.id())?.state();
542        self.record_interruption(motion.id(), state, InterruptionReason::Removed);
543        self.remove_slot(motion.id(), RemovalReason::Explicit);
544        Ok(())
545    }
546
547    fn current_playback(&self, id: MotionId) -> PlaybackId {
548        PlaybackId::new(id, self.slots[id.slot()].playback_revision)
549    }
550
551    fn start_playback(&mut self, id: MotionId) -> PlaybackId {
552        let slot = &mut self.slots[id.slot()];
553        slot.playback_revision = slot.playback_revision.wrapping_add(1);
554        slot.terminal_emitted = false;
555        PlaybackId::new(id, slot.playback_revision)
556    }
557
558    fn record_terminal(&mut self, id: MotionId, state: AnimationState) {
559        let kind = match state {
560            AnimationState::Completed => MotionEventKind::Completed,
561            AnimationState::Canceled => MotionEventKind::Canceled,
562            AnimationState::Idle | AnimationState::Running | AnimationState::Paused => return,
563        };
564        let playback = {
565            let slot = &mut self.slots[id.slot()];
566            if slot.generation != id.generation() || slot.terminal_emitted {
567                return;
568            }
569            slot.terminal_emitted = true;
570            PlaybackId::new(id, slot.playback_revision)
571        };
572        self.events.push(MotionEvent::new(playback, kind));
573    }
574
575    fn record_interruption(
576        &mut self,
577        id: MotionId,
578        state: AnimationState,
579        reason: InterruptionReason,
580    ) {
581        if !matches!(state, AnimationState::Running | AnimationState::Paused) {
582            return;
583        }
584        let playback = {
585            let slot = &mut self.slots[id.slot()];
586            if slot.generation != id.generation() || slot.terminal_emitted {
587                return;
588            }
589            slot.terminal_emitted = true;
590            PlaybackId::new(id, slot.playback_revision)
591        };
592        self.events.push(MotionEvent::new(
593            playback,
594            MotionEventKind::Interrupted(reason),
595        ));
596    }
597
598    fn sync_slot_after_change(&mut self, id: MotionId) {
599        let Ok(animation) = self.animation(id) else {
600            return;
601        };
602        let active = animation.is_active();
603        let state = animation.state();
604        let retain_policy = self.slots[id.slot()].retain_policy;
605
606        if active {
607            self.activate(id);
608            return;
609        }
610
611        self.deactivate(id);
612        self.record_terminal(id, state);
613        if retain_policy == RetainPolicy::DropWhenSettled
614            && matches!(state, AnimationState::Completed | AnimationState::Canceled)
615        {
616            self.remove_slot(id, RemovalReason::Settled);
617        } else if let Ok(animation) = self.animation_mut(id) {
618            animation.compact();
619        }
620    }
621
622    fn remove_slot(&mut self, id: MotionId, reason: RemovalReason) {
623        let playback = self.current_playback(id);
624        let slot = &mut self.slots[id.slot()];
625        let was_active = slot.active;
626        slot.animation = None;
627        slot.active = false;
628        slot.queued = false;
629        if was_active {
630            self.active_count = self.active_count.saturating_sub(1);
631        }
632        self.motion_count = self.motion_count.saturating_sub(1);
633        self.free.push(id.slot());
634        self.events
635            .push(MotionEvent::new(playback, MotionEventKind::Removed(reason)));
636
637        #[cfg(feature = "tracing")]
638        tracing::debug!(
639            target: "aura_anim::runtime",
640            slot = id.slot(),
641            generation = id.generation(),
642            was_active,
643            motion_count = self.motion_count,
644            ?reason,
645            "removed motion"
646        );
647
648        if self.active_count == 0 {
649            self.active.clear();
650            self.next_active.clear();
651            self.last_tick = None;
652        }
653    }
654
655    fn animation(&self, id: MotionId) -> Result<&dyn AnimationDyn, MotionError> {
656        let Some(slot) = self.slots.get(id.slot()) else {
657            let error = MotionError::SlotOutOfBounds { slot: id.slot() };
658            #[cfg(feature = "tracing")]
659            trace_motion_error(id, &error);
660            return Err(error);
661        };
662        if slot.generation != id.generation() {
663            let error = MotionError::StaleHandle {
664                slot: id.slot(),
665                handle_generation: id.generation(),
666                actual_generation: slot.generation,
667            };
668            #[cfg(feature = "tracing")]
669            trace_motion_error(id, &error);
670            return Err(error);
671        }
672        slot.animation.as_deref().ok_or_else(|| {
673            let error = MotionError::Removed { slot: id.slot() };
674            #[cfg(feature = "tracing")]
675            trace_motion_error(id, &error);
676            error
677        })
678    }
679
680    fn animation_mut(&mut self, id: MotionId) -> Result<&mut (dyn AnimationDyn + '_), MotionError> {
681        let Some(slot) = self.slots.get_mut(id.slot()) else {
682            let error = MotionError::SlotOutOfBounds { slot: id.slot() };
683            #[cfg(feature = "tracing")]
684            trace_motion_error(id, &error);
685            return Err(error);
686        };
687        if slot.generation != id.generation() {
688            let error = MotionError::StaleHandle {
689                slot: id.slot(),
690                handle_generation: id.generation(),
691                actual_generation: slot.generation,
692            };
693            #[cfg(feature = "tracing")]
694            trace_motion_error(id, &error);
695            return Err(error);
696        }
697        if let Some(animation) = slot.animation.as_mut() {
698            Ok(animation.as_mut())
699        } else {
700            let error = MotionError::Removed { slot: id.slot() };
701            #[cfg(feature = "tracing")]
702            trace_motion_error(id, &error);
703            Err(error)
704        }
705    }
706
707    fn replace(
708        &mut self,
709        id: MotionId,
710        animation: TypedAnimation<impl Animatable>,
711        reason: InterruptionReason,
712    ) -> Result<PlaybackId, MotionError> {
713        let previous_state = self.animation(id)?.state();
714        self.record_interruption(id, previous_state, reason);
715        let playback = self.start_playback(id);
716        let slot = &mut self.slots[id.slot()];
717        slot.animation = Some(Box::new(animation));
718        self.sync_slot_after_change(id);
719        Ok(playback)
720    }
721
722    fn activate(&mut self, id: MotionId) {
723        let Some(slot) = self.slots.get_mut(id.slot()) else {
724            return;
725        };
726        if slot.generation != id.generation() || slot.animation.is_none() {
727            return;
728        }
729        if self.active_count == 0 {
730            self.last_tick = None;
731        }
732        if !slot.active {
733            slot.active = true;
734            self.active_count += 1;
735        }
736        if !slot.queued {
737            slot.queued = true;
738            self.active.push(id);
739        }
740    }
741
742    fn deactivate(&mut self, id: MotionId) {
743        let Some(slot) = self.slots.get_mut(id.slot()) else {
744            return;
745        };
746        if slot.generation != id.generation() || !slot.active {
747            return;
748        }
749        slot.active = false;
750        self.active_count = self.active_count.saturating_sub(1);
751        if self.active_count == 0 {
752            for active in &self.active {
753                if let Some(slot) = self.slots.get_mut(active.slot())
754                    && slot.generation == active.generation()
755                {
756                    slot.queued = false;
757                }
758            }
759            self.active.clear();
760            self.next_active.clear();
761            self.last_tick = None;
762        }
763    }
764}
765
766#[cfg(feature = "tracing")]
767fn trace_motion_error(id: MotionId, error: &MotionError) {
768    tracing::debug!(
769        target: "aura_anim::runtime",
770        slot = id.slot(),
771        generation = id.generation(),
772        error = %error,
773        "motion lookup failed"
774    );
775}
776
777#[cfg(test)]
778mod tests {
779    use super::MotionRuntime;
780    use crate::{
781        Tween,
782        timing::{Duration, Timing},
783    };
784
785    #[test]
786    fn remove_lazily_invalidates_queued_ids() {
787        let mut runtime = MotionRuntime::new();
788        let first = runtime.insert(
789            Tween::between(0.0_f32, 1.0, Timing::new(100.0)),
790            Timing::default(),
791        );
792        let second = runtime.insert(
793            Tween::between(0.0_f32, 1.0, Timing::new(100.0)),
794            Timing::default(),
795        );
796
797        runtime.tick(Duration::ZERO);
798        assert_eq!(runtime.active.len(), 2);
799        assert_eq!(runtime.next_active.len(), 2);
800
801        first.remove(&mut runtime).unwrap();
802        assert_eq!(runtime.active_count(), 1);
803        assert_eq!(runtime.active.len(), 2);
804        assert_eq!(runtime.next_active.len(), 2);
805
806        let replacement = runtime.insert(
807            Tween::between(0.0_f32, 1.0, Timing::new(100.0)),
808            Timing::default(),
809        );
810        assert_eq!(runtime.active_count(), 2);
811        assert_eq!(runtime.active.len(), 3);
812
813        runtime.tick(Duration::ZERO);
814
815        assert_eq!(runtime.active_count(), 2);
816        assert_eq!(runtime.active.len(), 2);
817        assert!(second.is_active(&runtime).unwrap());
818        assert!(replacement.is_active(&runtime).unwrap());
819    }
820}