Skip to main content

aura_anim_core/
binding.rs

1//! Declarative business-state to motion bindings.
2
3use std::sync::Arc;
4
5mod error;
6
7pub use error::MotionBindingError;
8
9use crate::{
10    Animatable, Animation, BoxAnimation, Motion, MotionRuntime, PlaybackId, Spring, SpringConfig,
11    Tween, timing::Timing,
12};
13
14/// Owned values supplied to a [`MotionBinding`] transition factory.
15///
16/// `from` is always the motion's current sampled value, not the target
17/// associated with `from_state`. This keeps interrupted transitions visually
18/// continuous.
19#[derive(Debug, Clone)]
20pub struct TransitionContext<S, T> {
21    /// Business state that was active before this transition.
22    pub from_state: S,
23    /// Business state being applied.
24    pub to_state: S,
25    /// Current sampled motion value.
26    pub from: T,
27    /// Target value associated with `to_state`.
28    pub to: T,
29}
30
31impl<S, T: Animatable> TransitionContext<S, T> {
32    /// Builds a tween from the current sampled value to the resolved target.
33    #[must_use]
34    pub fn tween(self, timing: Timing) -> Tween<T> {
35        Tween::between(self.from, self.to, timing)
36    }
37
38    /// Builds a spring from the current sampled value to the resolved target.
39    #[must_use]
40    pub fn spring(self, config: SpringConfig) -> Spring<T> {
41        Spring::new(self.from, self.to, config)
42    }
43}
44
45/// Per-consumer state used with a reusable [`MotionBinding`].
46///
47/// Keep one value next to each bound [`Motion`]. The binding itself remains
48/// immutable and can be shared by any number of controls.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct MotionBindingState<S> {
51    previous: S,
52}
53
54impl<S> MotionBindingState<S> {
55    /// Creates state tracking from `previous`.
56    #[must_use]
57    pub const fn new(previous: S) -> Self {
58        Self { previous }
59    }
60
61    /// Returns the last successfully applied business state.
62    #[must_use]
63    pub const fn current(&self) -> &S {
64        &self.previous
65    }
66}
67
68type TransitionFactory<S, T> = Arc<dyn Fn(TransitionContext<S, T>) -> BoxAnimation<T> + 'static>;
69
70#[derive(Clone)]
71struct Transition<S, T: Animatable> {
72    from: S,
73    to: S,
74    factory: TransitionFactory<S, T>,
75}
76
77/// Reusable configuration that maps business states to motion targets.
78///
79/// A binding contains no mutable playback state. Call [`MotionBinding::state`]
80/// once per consumer, then pass that state to [`MotionBinding::set_state`].
81/// Exact `(from, to)` factories take precedence over the fallback factory.
82///
83/// # Examples
84///
85/// ```
86/// use aura_anim_core::{MotionBinding, MotionRuntime, timing::{Duration, Timing}};
87///
88/// #[derive(Clone, Copy, Debug, PartialEq, Eq)]
89/// enum ButtonState {
90///     Idle,
91///     Hovered,
92/// }
93///
94/// let binding = MotionBinding::new(ButtonState::Idle, 0.8_f32)
95///     .when(ButtonState::Hovered, 1.0)
96///     .transition(ButtonState::Idle, ButtonState::Hovered, |context| {
97///         context.tween(Timing::ease_out(120.0))
98///     })
99///     .fallback(|context| context.tween(Timing::linear(100.0)));
100///
101/// let mut runtime = MotionRuntime::new();
102/// let (motion, mut state) = binding.create_motion(&mut runtime);
103/// let playback = binding
104///     .set_state_tracked(
105///         &mut state,
106///         ButtonState::Hovered,
107///         motion,
108///         &mut runtime,
109///     )
110///     .unwrap()
111///     .unwrap();
112/// runtime.tick(Duration::from_millis(120.0));
113///
114/// assert_eq!(motion.value(&runtime).unwrap(), 1.0);
115/// assert!(runtime.take_events()[0].is_completed_for(playback));
116/// ```
117#[derive(Clone)]
118pub struct MotionBinding<S, T: Animatable> {
119    initial_state: S,
120    initial_target: T,
121    targets: Vec<(S, T)>,
122    transitions: Vec<Transition<S, T>>,
123    fallback: Option<TransitionFactory<S, T>>,
124}
125
126impl<S, T> MotionBinding<S, T>
127where
128    S: Clone + PartialEq + 'static,
129    T: Animatable,
130{
131    /// Creates a binding with its initial business state and target value.
132    #[must_use]
133    pub fn new(initial_state: S, initial_target: T) -> Self {
134        Self {
135            initial_state: initial_state.clone(),
136            initial_target: initial_target.clone(),
137            targets: vec![(initial_state, initial_target)],
138            transitions: Vec::new(),
139            fallback: None,
140        }
141    }
142
143    /// Adds or replaces the target associated with `state`.
144    #[must_use]
145    pub fn when(mut self, state: S, target: T) -> Self {
146        if state == self.initial_state {
147            self.initial_target = target.clone();
148        }
149        if let Some((_, existing)) = self
150            .targets
151            .iter_mut()
152            .find(|(existing, _)| existing == &state)
153        {
154            *existing = target;
155        } else {
156            self.targets.push((state, target));
157        }
158        self
159    }
160
161    /// Adds or replaces an exact `(from, to)` transition factory.
162    ///
163    /// The factory may return any concrete [`Animation`];
164    /// the binding handles type erasure internally.
165    #[must_use]
166    pub fn transition<A>(
167        mut self,
168        from: S,
169        to: S,
170        factory: impl Fn(TransitionContext<S, T>) -> A + 'static,
171    ) -> Self
172    where
173        A: Animation<T>,
174    {
175        let factory: TransitionFactory<S, T> = Arc::new(move |context| Box::new(factory(context)));
176        if let Some(existing) = self
177            .transitions
178            .iter_mut()
179            .find(|transition| transition.from == from && transition.to == to)
180        {
181            existing.factory = factory;
182        } else {
183            self.transitions.push(Transition { from, to, factory });
184        }
185        self
186    }
187
188    /// Sets the factory used when no exact transition is configured.
189    ///
190    /// The factory may return any concrete [`Animation`];
191    /// the binding handles type erasure internally.
192    #[must_use]
193    pub fn fallback<A>(mut self, factory: impl Fn(TransitionContext<S, T>) -> A + 'static) -> Self
194    where
195        A: Animation<T>,
196    {
197        self.fallback = Some(Arc::new(move |context| Box::new(factory(context))));
198        self
199    }
200
201    /// Returns the initial business state.
202    #[must_use]
203    pub const fn initial_state(&self) -> &S {
204        &self.initial_state
205    }
206
207    /// Returns the target associated with `state`.
208    pub fn target(&self, state: &S) -> Result<&T, MotionBindingError<S>> {
209        let target = self
210            .targets
211            .iter()
212            .find_map(|(candidate, target)| (candidate == state).then_some(target));
213
214        #[cfg(feature = "tracing")]
215        if target.is_none() {
216            tracing::debug!(
217                target: "aura_anim::binding",
218                state_type = std::any::type_name::<S>(),
219                value_type = std::any::type_name::<T>(),
220                "motion binding target lookup failed"
221            );
222        }
223        target.ok_or_else(|| MotionBindingError::MissingTarget(state.clone()))
224    }
225
226    /// Creates independent state tracking for one consumer.
227    #[must_use]
228    pub fn state(&self) -> MotionBindingState<S> {
229        MotionBindingState::new(self.initial_state.clone())
230    }
231
232    /// Creates a motion at the initial target and its independent state tracker.
233    pub fn create_motion(&self, runtime: &mut MotionRuntime) -> (Motion<T>, MotionBindingState<S>) {
234        (runtime.motion(self.initial_target.clone()), self.state())
235    }
236
237    /// Applies `next_state` to an existing motion.
238    ///
239    /// Returns `Ok(false)` when the requested state is already current.
240    /// Use [`MotionBinding::set_state_tracked`] when the concrete playback ID
241    /// is needed for event matching.
242    pub fn set_state(
243        &self,
244        binding_state: &mut MotionBindingState<S>,
245        next_state: S,
246        motion: Motion<T>,
247        runtime: &mut MotionRuntime,
248    ) -> Result<bool, MotionBindingError<S>> {
249        self.set_state_tracked(binding_state, next_state, motion, runtime)
250            .map(|playback| playback.is_some())
251    }
252
253    /// Applies `next_state` and returns the concrete playback ID.
254    ///
255    /// Returns `Ok(None)` when the requested state is already current.
256    /// Target and transition lookup happen before the motion is modified.
257    /// The state tracker is updated only after `motion.play_tracked` succeeds.
258    pub fn set_state_tracked(
259        &self,
260        binding_state: &mut MotionBindingState<S>,
261        next_state: S,
262        motion: Motion<T>,
263        runtime: &mut MotionRuntime,
264    ) -> Result<Option<PlaybackId>, MotionBindingError<S>> {
265        if binding_state.previous == next_state {
266            #[cfg(feature = "tracing")]
267            tracing::trace!(
268                target: "aura_anim::binding",
269                state_type = std::any::type_name::<S>(),
270                value_type = std::any::type_name::<T>(),
271                "motion binding state is unchanged"
272            );
273            return Ok(None);
274        }
275
276        let target = self.target(&next_state).cloned()?;
277        let previous = binding_state.previous.clone();
278        let exact_factory = self
279            .transitions
280            .iter()
281            .find(|transition| transition.from == previous && transition.to == next_state)
282            .map(|transition| &transition.factory);
283        let Some(factory) = exact_factory.or(self.fallback.as_ref()) else {
284            #[cfg(feature = "tracing")]
285            tracing::debug!(
286                target: "aura_anim::binding",
287                state_type = std::any::type_name::<S>(),
288                value_type = std::any::type_name::<T>(),
289                "motion binding transition lookup failed"
290            );
291            return Err(MotionBindingError::MissingTransition {
292                from: previous.clone(),
293                to: next_state.clone(),
294            });
295        };
296        let current = motion.value(runtime)?;
297        #[cfg(feature = "tracing")]
298        {
299            let uses_fallback = exact_factory.is_none();
300            tracing::debug!(
301                target: "aura_anim::binding",
302                state_type = std::any::type_name::<S>(),
303                value_type = std::any::type_name::<T>(),
304                uses_fallback,
305                "applying motion binding transition"
306            );
307            let _ = uses_fallback;
308        }
309        let animation = factory(TransitionContext {
310            from_state: previous,
311            to_state: next_state.clone(),
312            from: current,
313            to: target,
314        });
315
316        let playback = motion.play_tracked(animation, runtime)?;
317
318        binding_state.previous = next_state;
319        #[cfg(feature = "tracing")]
320        tracing::debug!(
321            target: "aura_anim::binding",
322            state_type = std::any::type_name::<S>(),
323            value_type = std::any::type_name::<T>(),
324            "committed motion binding state"
325        );
326        Ok(Some(playback))
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::{MotionBinding, MotionBindingError};
333    use crate::{
334        AnimationExt, MotionRuntime, Sequence, SpringConfig, Tween,
335        keyframes::Keyframes,
336        timing::{Duration, Timing},
337    };
338    use float_cmp::assert_approx_eq;
339
340    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
341    enum State {
342        Idle,
343        Hovered,
344        Pressed,
345        Disabled,
346    }
347
348    fn binding() -> MotionBinding<State, f32> {
349        MotionBinding::new(State::Idle, 0.0)
350            .when(State::Hovered, 10.0)
351            .when(State::Pressed, 20.0)
352            .transition(State::Idle, State::Hovered, |context| {
353                context.tween(Timing::new(100.0))
354            })
355            .transition(State::Hovered, State::Pressed, |context| {
356                context.spring(SpringConfig::default())
357            })
358            .fallback(|context| context.tween(Timing::new(50.0)))
359    }
360
361    #[test]
362    fn exact_transition_uses_current_sampled_value() {
363        let binding = binding();
364        let mut runtime = MotionRuntime::new();
365        let (motion, mut state) = binding.create_motion(&mut runtime);
366
367        binding
368            .set_state(&mut state, State::Hovered, motion, &mut runtime)
369            .unwrap();
370        runtime.tick(Duration::from_millis(40.0));
371        assert_approx_eq!(f32, motion.value(&runtime).unwrap(), 4.0);
372
373        binding
374            .set_state(&mut state, State::Pressed, motion, &mut runtime)
375            .unwrap();
376
377        assert_approx_eq!(f32, motion.value(&runtime).unwrap(), 4.0);
378        assert_eq!(state.current(), &State::Pressed);
379    }
380
381    #[test]
382    fn fallback_handles_unlisted_state_pair() {
383        let binding = binding();
384        let mut runtime = MotionRuntime::new();
385        let (motion, mut state) = binding.create_motion(&mut runtime);
386
387        binding
388            .set_state(&mut state, State::Pressed, motion, &mut runtime)
389            .unwrap();
390        runtime.tick(Duration::from_millis(50.0));
391
392        assert_approx_eq!(f32, motion.value(&runtime).unwrap(), 20.0);
393    }
394
395    #[test]
396    fn tracked_state_change_returns_playback_for_completion_event() {
397        let binding = binding();
398        let mut runtime = MotionRuntime::new();
399        let (motion, mut state) = binding.create_motion(&mut runtime);
400
401        let playback = binding
402            .set_state_tracked(&mut state, State::Hovered, motion, &mut runtime)
403            .unwrap()
404            .unwrap();
405
406        assert_eq!(motion.playback(&runtime).unwrap(), playback);
407        assert_eq!(
408            binding
409                .set_state_tracked(&mut state, State::Hovered, motion, &mut runtime)
410                .unwrap(),
411            None
412        );
413
414        runtime.tick(Duration::from_millis(100.0));
415        let events = runtime.take_events();
416        assert_eq!(events.len(), 1);
417        assert!(events[0].is_completed_for(playback));
418    }
419
420    #[test]
421    fn failed_lookup_does_not_commit_state() {
422        let binding = binding();
423        let mut runtime = MotionRuntime::new();
424        let (motion, mut state) = binding.create_motion(&mut runtime);
425
426        let error = binding
427            .set_state(&mut state, State::Disabled, motion, &mut runtime)
428            .unwrap_err();
429
430        assert_eq!(error, MotionBindingError::MissingTarget(State::Disabled));
431        assert_eq!(state.current(), &State::Idle);
432    }
433
434    #[test]
435    fn shared_configuration_creates_independent_trackers() {
436        let binding = binding();
437        let mut runtime = MotionRuntime::new();
438        let (first, mut first_state) = binding.create_motion(&mut runtime);
439        let (second, second_state) = binding.create_motion(&mut runtime);
440
441        binding
442            .set_state(&mut first_state, State::Hovered, first, &mut runtime)
443            .unwrap();
444
445        assert_eq!(first_state.current(), &State::Hovered);
446        assert_eq!(second_state.current(), &State::Idle);
447        assert_approx_eq!(f32, second.value(&runtime).unwrap(), 0.0);
448    }
449
450    #[test]
451    fn missing_transition_is_reported_before_playback() {
452        let binding = MotionBinding::new(State::Idle, 0.0).when(State::Hovered, 1.0);
453        let mut runtime = MotionRuntime::new();
454        let (motion, mut state) = binding.create_motion(&mut runtime);
455
456        let error = binding
457            .set_state(&mut state, State::Hovered, motion, &mut runtime)
458            .unwrap_err();
459
460        assert_eq!(
461            error,
462            MotionBindingError::MissingTransition {
463                from: State::Idle,
464                to: State::Hovered,
465            }
466        );
467        assert!(!motion.is_active(&runtime).unwrap());
468    }
469
470    #[test]
471    fn runtime_errors_are_preserved_by_binding_errors() {
472        let binding = binding();
473        let mut runtime = MotionRuntime::new();
474        let (motion, mut state) = binding.create_motion(&mut runtime);
475        motion.remove(&mut runtime).unwrap();
476
477        let error = binding
478            .set_state(&mut state, State::Hovered, motion, &mut runtime)
479            .unwrap_err();
480
481        assert_eq!(
482            error,
483            MotionBindingError::Motion(crate::MotionError::Removed { slot: 0 })
484        );
485        assert_eq!(state.current(), &State::Idle);
486    }
487
488    #[test]
489    fn factories_accept_keyframes_and_timeline_sources() {
490        let binding = MotionBinding::new(State::Idle, 0.0_f32)
491            .when(State::Hovered, 10.0)
492            .when(State::Pressed, 20.0)
493            .transition(State::Idle, State::Hovered, |context| {
494                Keyframes::new(context.from).push(100.0, context.to)
495            })
496            .transition(State::Hovered, State::Pressed, |context| {
497                Sequence::new(context.from).then(Tween::between(
498                    context.from,
499                    context.to,
500                    Timing::new(100.0),
501                ))
502            });
503        let mut runtime = MotionRuntime::new();
504        let (motion, mut state) = binding.create_motion(&mut runtime);
505
506        binding
507            .set_state(&mut state, State::Hovered, motion, &mut runtime)
508            .unwrap();
509        runtime.tick(Duration::from_millis(100.0));
510        assert_approx_eq!(f32, motion.value(&runtime).unwrap(), 10.0);
511
512        binding
513            .set_state(&mut state, State::Pressed, motion, &mut runtime)
514            .unwrap();
515        runtime.tick(Duration::from_millis(100.0));
516        assert_approx_eq!(f32, motion.value(&runtime).unwrap(), 20.0);
517    }
518
519    #[test]
520    fn boxed_factories_remain_supported() {
521        let binding = MotionBinding::new(State::Idle, 0.0_f32)
522            .when(State::Hovered, 10.0)
523            .fallback(|context| context.tween(Timing::new(100.0)).boxed());
524        let mut runtime = MotionRuntime::new();
525        let (motion, mut state) = binding.create_motion(&mut runtime);
526
527        binding
528            .set_state(&mut state, State::Hovered, motion, &mut runtime)
529            .unwrap();
530        runtime.tick(Duration::from_millis(100.0));
531
532        assert_approx_eq!(f32, motion.value(&runtime).unwrap(), 10.0);
533    }
534}