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