Skip to main content

embedded_gui/
widget_animation.rs

1//! Widget-level animation bindings built on top of [`AnimationManager`].
2//! Keeps fixed-capacity, no-allocation behavior suitable for embedded targets.
3
4use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
5use heapless::Vec;
6
7#[cfg(not(feature = "std"))]
8use crate::math::F32Ext as _;
9use crate::{
10    animation::{Animation, AnimationError, AnimationId, AnimationManager, Easing, PathPoint},
11    context::{GuiContext, GuiError},
12    widget::WidgetId,
13};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum WidgetAnimationError {
17    AnimationsFull,
18    BindingsFull,
19    ConflictIgnored,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum AnimatedProperty {
24    Progress,
25    Meter,
26    SliderValue,
27    ScrollOffsetY,
28    TabSelected,
29    DropdownSelected,
30    RollerSelected,
31    GaugeValue,
32    SpinnerPhase,
33    CornerRadius,
34    AccentR,
35    AccentG,
36    AccentB,
37    WidgetX,
38    WidgetY,
39    WidgetWidth,
40    WidgetHeight,
41    Opacity,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct WidgetKeyframeState {
46    pub x: i32,
47    pub y: i32,
48    pub opacity: u8,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct WidgetPropertyKeyframe {
53    pub x: Option<i32>,
54    pub y: Option<i32>,
55    pub opacity: Option<u8>,
56    pub duration_ms: u32,
57    pub easing: Easing,
58}
59
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum AnimationConflictPolicy {
62    #[default]
63    Replace,
64    Ignore,
65    Queue,
66}
67
68#[derive(Clone, Copy, Debug, Default)]
69pub struct WidgetAnimationCallbacks {
70    pub on_start: Option<fn(AnimationId, WidgetId, AnimatedProperty)>,
71    pub on_repeat: Option<fn(AnimationId, WidgetId, AnimatedProperty)>,
72    pub on_complete: Option<fn(AnimationId, WidgetId, AnimatedProperty)>,
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76struct Binding {
77    animation_id: AnimationId,
78    widget_id: WidgetId,
79    property: AnimatedProperty,
80    last_iteration: u16,
81    queued: bool,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub struct BindingSnapshot {
86    pub animation_id: AnimationId,
87    pub widget_id: WidgetId,
88    pub property: AnimatedProperty,
89    pub queued: bool,
90}
91
92#[derive(Clone, Copy, Debug)]
93pub struct WidgetAnimator<const TRACKS: usize, const BINDINGS: usize> {
94    animations: AnimationManager<TRACKS>,
95    bindings: [Option<Binding>; BINDINGS],
96    callbacks: WidgetAnimationCallbacks,
97}
98
99impl<const TRACKS: usize, const BINDINGS: usize> Default for WidgetAnimator<TRACKS, BINDINGS> {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl<const TRACKS: usize, const BINDINGS: usize> WidgetAnimator<TRACKS, BINDINGS> {
106    pub const fn new() -> Self {
107        Self {
108            animations: AnimationManager::new(),
109            bindings: [None; BINDINGS],
110            callbacks: WidgetAnimationCallbacks {
111                on_start: None,
112                on_repeat: None,
113                on_complete: None,
114            },
115        }
116    }
117
118    pub fn set_callbacks(&mut self, callbacks: WidgetAnimationCallbacks) {
119        self.callbacks = callbacks;
120    }
121
122    pub fn animate_progress(
123        &mut self,
124        widget_id: WidgetId,
125        from: f32,
126        to: f32,
127        duration_ms: u32,
128        easing: Easing,
129    ) -> Result<AnimationId, WidgetAnimationError> {
130        self.bind_property(
131            widget_id,
132            AnimatedProperty::Progress,
133            Animation::new(from, to, duration_ms, easing),
134        )
135    }
136
137    pub fn animate_meter(
138        &mut self,
139        widget_id: WidgetId,
140        from: f32,
141        to: f32,
142        duration_ms: u32,
143        easing: Easing,
144    ) -> Result<AnimationId, WidgetAnimationError> {
145        self.bind_property(
146            widget_id,
147            AnimatedProperty::Meter,
148            Animation::new(from, to, duration_ms, easing),
149        )
150    }
151
152    pub fn animate_slider_value(
153        &mut self,
154        widget_id: WidgetId,
155        from: f32,
156        to: f32,
157        duration_ms: u32,
158        easing: Easing,
159    ) -> Result<AnimationId, WidgetAnimationError> {
160        self.animate_slider_value_with_policy(
161            widget_id,
162            from,
163            to,
164            duration_ms,
165            easing,
166            AnimationConflictPolicy::Replace,
167        )
168    }
169
170    pub fn animate_slider_value_with_policy(
171        &mut self,
172        widget_id: WidgetId,
173        from: f32,
174        to: f32,
175        duration_ms: u32,
176        easing: Easing,
177        policy: AnimationConflictPolicy,
178    ) -> Result<AnimationId, WidgetAnimationError> {
179        self.bind_property_with_policy(
180            widget_id,
181            AnimatedProperty::SliderValue,
182            Animation::new(from, to, duration_ms, easing),
183            policy,
184        )
185    }
186
187    pub fn animate_scroll_offset_y(
188        &mut self,
189        widget_id: WidgetId,
190        from: i32,
191        to: i32,
192        duration_ms: u32,
193        easing: Easing,
194    ) -> Result<AnimationId, WidgetAnimationError> {
195        self.animate_scroll_offset_y_with_policy(
196            widget_id,
197            from,
198            to,
199            duration_ms,
200            easing,
201            AnimationConflictPolicy::Replace,
202        )
203    }
204
205    pub fn animate_scroll_offset_y_with_policy(
206        &mut self,
207        widget_id: WidgetId,
208        from: i32,
209        to: i32,
210        duration_ms: u32,
211        easing: Easing,
212        policy: AnimationConflictPolicy,
213    ) -> Result<AnimationId, WidgetAnimationError> {
214        self.bind_property_with_policy(
215            widget_id,
216            AnimatedProperty::ScrollOffsetY,
217            Animation::new(from as f32, to as f32, duration_ms, easing),
218            policy,
219        )
220    }
221
222    pub fn animate_tab_selected(
223        &mut self,
224        widget_id: WidgetId,
225        from: usize,
226        to: usize,
227        duration_ms: u32,
228        easing: Easing,
229    ) -> Result<AnimationId, WidgetAnimationError> {
230        self.bind_property(
231            widget_id,
232            AnimatedProperty::TabSelected,
233            Animation::new(from as f32, to as f32, duration_ms, easing),
234        )
235    }
236
237    pub fn animate_dropdown_selected(
238        &mut self,
239        widget_id: WidgetId,
240        from: usize,
241        to: usize,
242        duration_ms: u32,
243        easing: Easing,
244    ) -> Result<AnimationId, WidgetAnimationError> {
245        self.bind_property(
246            widget_id,
247            AnimatedProperty::DropdownSelected,
248            Animation::new(from as f32, to as f32, duration_ms, easing),
249        )
250    }
251
252    pub fn animate_roller_selected(
253        &mut self,
254        widget_id: WidgetId,
255        from: usize,
256        to: usize,
257        duration_ms: u32,
258        easing: Easing,
259    ) -> Result<AnimationId, WidgetAnimationError> {
260        self.bind_property(
261            widget_id,
262            AnimatedProperty::RollerSelected,
263            Animation::new(from as f32, to as f32, duration_ms, easing),
264        )
265    }
266
267    pub fn animate_gauge_value(
268        &mut self,
269        widget_id: WidgetId,
270        from: f32,
271        to: f32,
272        duration_ms: u32,
273        easing: Easing,
274    ) -> Result<AnimationId, WidgetAnimationError> {
275        self.bind_property(
276            widget_id,
277            AnimatedProperty::GaugeValue,
278            Animation::new(from, to, duration_ms, easing),
279        )
280    }
281
282    pub fn animate_spinner_phase(
283        &mut self,
284        widget_id: WidgetId,
285        from: f32,
286        to: f32,
287        duration_ms: u32,
288        easing: Easing,
289    ) -> Result<AnimationId, WidgetAnimationError> {
290        self.bind_property(
291            widget_id,
292            AnimatedProperty::SpinnerPhase,
293            Animation::new(from, to, duration_ms, easing),
294        )
295    }
296
297    pub fn animate_widget_x(
298        &mut self,
299        widget_id: WidgetId,
300        from: i32,
301        to: i32,
302        duration_ms: u32,
303        easing: Easing,
304    ) -> Result<AnimationId, WidgetAnimationError> {
305        self.animate_widget_x_with_policy(
306            widget_id,
307            from,
308            to,
309            duration_ms,
310            easing,
311            AnimationConflictPolicy::Replace,
312        )
313    }
314
315    pub fn animate_widget_x_with_policy(
316        &mut self,
317        widget_id: WidgetId,
318        from: i32,
319        to: i32,
320        duration_ms: u32,
321        easing: Easing,
322        policy: AnimationConflictPolicy,
323    ) -> Result<AnimationId, WidgetAnimationError> {
324        self.bind_property_with_policy(
325            widget_id,
326            AnimatedProperty::WidgetX,
327            Animation::new(from as f32, to as f32, duration_ms, easing),
328            policy,
329        )
330    }
331
332    #[allow(clippy::too_many_arguments)]
333    pub fn animate_widget_x_with_custom_interpolator(
334        &mut self,
335        widget_id: WidgetId,
336        from: i32,
337        to: i32,
338        duration_ms: u32,
339        easing: Easing,
340        interpolator: fn(f32, f32, f32) -> f32,
341        policy: AnimationConflictPolicy,
342    ) -> Result<AnimationId, WidgetAnimationError> {
343        let animation = Animation::new(from as f32, to as f32, duration_ms, easing)
344            .with_custom_interpolator(interpolator);
345        self.bind_property_with_policy(widget_id, AnimatedProperty::WidgetX, animation, policy)
346    }
347
348    pub fn animate_widget_y(
349        &mut self,
350        widget_id: WidgetId,
351        from: i32,
352        to: i32,
353        duration_ms: u32,
354        easing: Easing,
355    ) -> Result<AnimationId, WidgetAnimationError> {
356        self.animate_widget_y_with_policy(
357            widget_id,
358            from,
359            to,
360            duration_ms,
361            easing,
362            AnimationConflictPolicy::Replace,
363        )
364    }
365
366    pub fn animate_widget_y_with_policy(
367        &mut self,
368        widget_id: WidgetId,
369        from: i32,
370        to: i32,
371        duration_ms: u32,
372        easing: Easing,
373        policy: AnimationConflictPolicy,
374    ) -> Result<AnimationId, WidgetAnimationError> {
375        self.bind_property_with_policy(
376            widget_id,
377            AnimatedProperty::WidgetY,
378            Animation::new(from as f32, to as f32, duration_ms, easing),
379            policy,
380        )
381    }
382
383    #[allow(clippy::too_many_arguments)]
384    pub fn animate_widget_y_with_custom_curve(
385        &mut self,
386        widget_id: WidgetId,
387        from: i32,
388        to: i32,
389        duration_ms: u32,
390        easing: Easing,
391        curve: fn(f32) -> f32,
392        policy: AnimationConflictPolicy,
393    ) -> Result<AnimationId, WidgetAnimationError> {
394        let animation =
395            Animation::new(from as f32, to as f32, duration_ms, easing).with_custom_curve(curve);
396        self.bind_property_with_policy(widget_id, AnimatedProperty::WidgetY, animation, policy)
397    }
398
399    pub fn animate_widget_width(
400        &mut self,
401        widget_id: WidgetId,
402        from: u32,
403        to: u32,
404        duration_ms: u32,
405        easing: Easing,
406    ) -> Result<AnimationId, WidgetAnimationError> {
407        self.animate_widget_width_with_policy(
408            widget_id,
409            from,
410            to,
411            duration_ms,
412            easing,
413            AnimationConflictPolicy::Replace,
414        )
415    }
416
417    pub fn animate_widget_width_with_policy(
418        &mut self,
419        widget_id: WidgetId,
420        from: u32,
421        to: u32,
422        duration_ms: u32,
423        easing: Easing,
424        policy: AnimationConflictPolicy,
425    ) -> Result<AnimationId, WidgetAnimationError> {
426        self.bind_property_with_policy(
427            widget_id,
428            AnimatedProperty::WidgetWidth,
429            Animation::new(from as f32, to as f32, duration_ms, easing),
430            policy,
431        )
432    }
433
434    pub fn animate_widget_height(
435        &mut self,
436        widget_id: WidgetId,
437        from: u32,
438        to: u32,
439        duration_ms: u32,
440        easing: Easing,
441    ) -> Result<AnimationId, WidgetAnimationError> {
442        self.animate_widget_height_with_policy(
443            widget_id,
444            from,
445            to,
446            duration_ms,
447            easing,
448            AnimationConflictPolicy::Replace,
449        )
450    }
451
452    pub fn animate_widget_height_with_policy(
453        &mut self,
454        widget_id: WidgetId,
455        from: u32,
456        to: u32,
457        duration_ms: u32,
458        easing: Easing,
459        policy: AnimationConflictPolicy,
460    ) -> Result<AnimationId, WidgetAnimationError> {
461        self.bind_property_with_policy(
462            widget_id,
463            AnimatedProperty::WidgetHeight,
464            Animation::new(from as f32, to as f32, duration_ms, easing),
465            policy,
466        )
467    }
468
469    pub fn animate_opacity(
470        &mut self,
471        widget_id: WidgetId,
472        from: u8,
473        to: u8,
474        duration_ms: u32,
475        easing: Easing,
476    ) -> Result<AnimationId, WidgetAnimationError> {
477        self.animate_opacity_with_policy(
478            widget_id,
479            from,
480            to,
481            duration_ms,
482            easing,
483            AnimationConflictPolicy::Replace,
484        )
485    }
486
487    pub fn animate_opacity_with_policy(
488        &mut self,
489        widget_id: WidgetId,
490        from: u8,
491        to: u8,
492        duration_ms: u32,
493        easing: Easing,
494        policy: AnimationConflictPolicy,
495    ) -> Result<AnimationId, WidgetAnimationError> {
496        self.bind_property_with_policy(
497            widget_id,
498            AnimatedProperty::Opacity,
499            Animation::new(from as f32, to as f32, duration_ms, easing),
500            policy,
501        )
502    }
503
504    #[allow(clippy::too_many_arguments)]
505    pub fn animate_opacity_with_custom_interpolator(
506        &mut self,
507        widget_id: WidgetId,
508        from: u8,
509        to: u8,
510        duration_ms: u32,
511        easing: Easing,
512        interpolator: fn(f32, f32, f32) -> f32,
513        policy: AnimationConflictPolicy,
514    ) -> Result<AnimationId, WidgetAnimationError> {
515        let animation = Animation::new(from as f32, to as f32, duration_ms, easing)
516            .with_custom_interpolator(interpolator);
517        self.bind_property_with_policy(widget_id, AnimatedProperty::Opacity, animation, policy)
518    }
519
520    pub fn animate_widget_keyframes(
521        &mut self,
522        widget_id: WidgetId,
523        initial: WidgetKeyframeState,
524        keyframes: &[WidgetPropertyKeyframe],
525        policy: AnimationConflictPolicy,
526    ) -> Result<usize, WidgetAnimationError> {
527        let mut created = 0usize;
528        let mut delay_ms = 0u32;
529        let mut state = initial;
530        for (idx, keyframe) in keyframes.iter().copied().enumerate() {
531            let step_policy = if idx == 0 {
532                policy
533            } else {
534                AnimationConflictPolicy::Queue
535            };
536            if let Some(next_x) = keyframe.x {
537                let anim = Animation::new(
538                    state.x as f32,
539                    next_x as f32,
540                    keyframe.duration_ms,
541                    keyframe.easing,
542                )
543                .with_delay(delay_ms);
544                self.bind_property_with_policy(
545                    widget_id,
546                    AnimatedProperty::WidgetX,
547                    anim,
548                    step_policy,
549                )?;
550                created += 1;
551                state.x = next_x;
552            }
553            if let Some(next_y) = keyframe.y {
554                let anim = Animation::new(
555                    state.y as f32,
556                    next_y as f32,
557                    keyframe.duration_ms,
558                    keyframe.easing,
559                )
560                .with_delay(delay_ms);
561                self.bind_property_with_policy(
562                    widget_id,
563                    AnimatedProperty::WidgetY,
564                    anim,
565                    step_policy,
566                )?;
567                created += 1;
568                state.y = next_y;
569            }
570            if let Some(next_opacity) = keyframe.opacity {
571                let anim = Animation::new(
572                    state.opacity as f32,
573                    next_opacity as f32,
574                    keyframe.duration_ms,
575                    keyframe.easing,
576                )
577                .with_delay(delay_ms);
578                self.bind_property_with_policy(
579                    widget_id,
580                    AnimatedProperty::Opacity,
581                    anim,
582                    step_policy,
583                )?;
584                created += 1;
585                state.opacity = next_opacity;
586            }
587            delay_ms = delay_ms.saturating_add(keyframe.duration_ms);
588        }
589        Ok(created)
590    }
591
592    pub fn pulse_opacity(
593        &mut self,
594        widget_id: WidgetId,
595        low: u8,
596        high: u8,
597        duration_ms: u32,
598        easing: Easing,
599    ) -> Result<AnimationId, WidgetAnimationError> {
600        let animation = Animation::new(low as f32, high as f32, duration_ms, easing)
601            .with_repeat_mode(crate::animation::RepeatMode::PingPong)
602            .with_repeat_count(None);
603        self.bind_property(widget_id, AnimatedProperty::Opacity, animation)
604    }
605
606    pub fn ping_pong_progress(
607        &mut self,
608        widget_id: WidgetId,
609        from: f32,
610        to: f32,
611        duration_ms: u32,
612        easing: Easing,
613    ) -> Result<AnimationId, WidgetAnimationError> {
614        let animation = Animation::new(from, to, duration_ms, easing)
615            .with_repeat_mode(crate::animation::RepeatMode::PingPong)
616            .with_repeat_count(None);
617        self.bind_property(widget_id, AnimatedProperty::Progress, animation)
618    }
619
620    pub fn animate_corner_radius(
621        &mut self,
622        widget_id: WidgetId,
623        from: u8,
624        to: u8,
625        duration_ms: u32,
626        easing: Easing,
627    ) -> Result<AnimationId, WidgetAnimationError> {
628        self.bind_property(
629            widget_id,
630            AnimatedProperty::CornerRadius,
631            Animation::new(from as f32, to as f32, duration_ms, easing),
632        )
633    }
634
635    pub fn animate_accent_color(
636        &mut self,
637        widget_id: WidgetId,
638        from: Rgb565,
639        to: Rgb565,
640        duration_ms: u32,
641        easing: Easing,
642    ) -> Result<[AnimationId; 3], WidgetAnimationError> {
643        let mut ids = [AnimationId::new(0); 3];
644        let mut started = Vec::<AnimationId, 3>::new();
645        let r = self.bind_property(
646            widget_id,
647            AnimatedProperty::AccentR,
648            Animation::new(from.r() as f32, to.r() as f32, duration_ms, easing),
649        )?;
650        ids[0] = r;
651        let _ = started.push(r);
652        let g = match self.bind_property(
653            widget_id,
654            AnimatedProperty::AccentG,
655            Animation::new(from.g() as f32, to.g() as f32, duration_ms, easing),
656        ) {
657            Ok(v) => v,
658            Err(err) => {
659                for id in started {
660                    let _ = self.stop(id);
661                }
662                return Err(err);
663            }
664        };
665        ids[1] = g;
666        let _ = started.push(g);
667        let b = match self.bind_property(
668            widget_id,
669            AnimatedProperty::AccentB,
670            Animation::new(from.b() as f32, to.b() as f32, duration_ms, easing),
671        ) {
672            Ok(v) => v,
673            Err(err) => {
674                for id in started {
675                    let _ = self.stop(id);
676                }
677                return Err(err);
678            }
679        };
680        ids[2] = b;
681        Ok(ids)
682    }
683
684    pub fn animate_widget_path(
685        &mut self,
686        widget_id: WidgetId,
687        points: &[PathPoint],
688        duration_ms: u32,
689        easing: Easing,
690    ) -> Result<(AnimationId, AnimationId), WidgetAnimationError> {
691        self.animate_widget_path_with_policy(
692            widget_id,
693            points,
694            duration_ms,
695            easing,
696            AnimationConflictPolicy::Replace,
697        )
698    }
699
700    pub fn animate_widget_path_with_policy(
701        &mut self,
702        widget_id: WidgetId,
703        points: &[PathPoint],
704        duration_ms: u32,
705        easing: Easing,
706        policy: AnimationConflictPolicy,
707    ) -> Result<(AnimationId, AnimationId), WidgetAnimationError> {
708        if points.len() < 2 {
709            return Err(WidgetAnimationError::ConflictIgnored);
710        }
711        let segs = (points.len() - 1) as u32;
712        let seg_duration = (duration_ms / segs).max(1);
713        let mut ids = Vec::<AnimationId, BINDINGS>::new();
714        let mut first_x = AnimationId::new(0);
715        let mut first_y = AnimationId::new(0);
716
717        for i in 0..(points.len() - 1) {
718            let from = points[i];
719            let to = points[i + 1];
720            let delay = seg_duration.saturating_mul(i as u32);
721            let x_anim = Animation::new(from.x, to.x, seg_duration, easing).with_delay(delay);
722            let y_anim = Animation::new(from.y, to.y, seg_duration, easing).with_delay(delay);
723            let step_policy = if i == 0 {
724                policy
725            } else {
726                AnimationConflictPolicy::Queue
727            };
728            let x_id = match self.bind_property_with_policy(
729                widget_id,
730                AnimatedProperty::WidgetX,
731                x_anim,
732                step_policy,
733            ) {
734                Ok(id) => id,
735                Err(err) => {
736                    for id in ids {
737                        let _ = self.stop(id);
738                    }
739                    return Err(err);
740                }
741            };
742            let _ = ids.push(x_id);
743            let y_id = match self.bind_property_with_policy(
744                widget_id,
745                AnimatedProperty::WidgetY,
746                y_anim,
747                step_policy,
748            ) {
749                Ok(id) => id,
750                Err(err) => {
751                    for id in ids {
752                        let _ = self.stop(id);
753                    }
754                    return Err(err);
755                }
756            };
757            let _ = ids.push(y_id);
758            if i == 0 {
759                first_x = x_id;
760                first_y = y_id;
761            }
762        }
763        Ok((first_x, first_y))
764    }
765
766    pub fn stagger_widget_x(
767        &mut self,
768        widget_ids: &[WidgetId],
769        from: i32,
770        to: i32,
771        duration_ms: u32,
772        stagger_ms: u32,
773        easing: Easing,
774    ) -> Result<usize, WidgetAnimationError> {
775        let mut created = 0usize;
776        let mut started = Vec::<AnimationId, BINDINGS>::new();
777        for (idx, id) in widget_ids.iter().copied().enumerate() {
778            let delay = stagger_ms.saturating_mul(idx as u32);
779            let animation =
780                Animation::new(from as f32, to as f32, duration_ms, easing).with_delay(delay);
781            match self.bind_property_with_policy(
782                id,
783                AnimatedProperty::WidgetX,
784                animation,
785                AnimationConflictPolicy::Replace,
786            ) {
787                Ok(track) => {
788                    let _ = started.push(track);
789                    created += 1;
790                }
791                Err(err) => {
792                    for track in started {
793                        let _ = self.stop(track);
794                    }
795                    return Err(err);
796                }
797            }
798        }
799        Ok(created)
800    }
801
802    pub fn preset_fade_in_up(
803        &mut self,
804        widget_id: WidgetId,
805        from_y: i32,
806        to_y: i32,
807        duration_ms: u32,
808    ) -> Result<(AnimationId, AnimationId), WidgetAnimationError> {
809        let y = self.animate_widget_y(widget_id, from_y, to_y, duration_ms, Easing::OutCubic)?;
810        let alpha = self.animate_opacity(widget_id, 0, 255, duration_ms, Easing::OutSine)?;
811        Ok((y, alpha))
812    }
813
814    pub fn preset_attention_shake(
815        &mut self,
816        widget_id: WidgetId,
817        base_x: i32,
818        amplitude: i32,
819        duration_ms: u32,
820    ) -> Result<(AnimationId, AnimationId), WidgetAnimationError> {
821        let step = (duration_ms / 3).max(1);
822        let a = self.bind_property_with_policy(
823            widget_id,
824            AnimatedProperty::WidgetX,
825            Animation::new(
826                base_x as f32,
827                (base_x + amplitude) as f32,
828                step,
829                Easing::InOutSine,
830            ),
831            AnimationConflictPolicy::Replace,
832        )?;
833        let b = self.bind_property_with_policy(
834            widget_id,
835            AnimatedProperty::WidgetX,
836            Animation::new(
837                (base_x + amplitude) as f32,
838                (base_x - amplitude) as f32,
839                step,
840                Easing::InOutSine,
841            )
842            .with_delay(step),
843            AnimationConflictPolicy::Queue,
844        )?;
845        self.bind_property_with_policy(
846            widget_id,
847            AnimatedProperty::WidgetX,
848            Animation::new(
849                (base_x - amplitude) as f32,
850                base_x as f32,
851                step,
852                Easing::InOutSine,
853            )
854            .with_delay(step.saturating_mul(2)),
855            AnimationConflictPolicy::Queue,
856        )?;
857        Ok((a, b))
858    }
859
860    pub fn preset_selection_bump_settle(
861        &mut self,
862        widget_id: WidgetId,
863        base_y: i32,
864        bump_px: i32,
865        duration_ms: u32,
866    ) -> Result<(AnimationId, AnimationId), WidgetAnimationError> {
867        let up_ms = (duration_ms / 3).max(1);
868        let settle_ms = duration_ms.saturating_sub(up_ms).max(1);
869        let bump_y = base_y - bump_px.abs();
870        let up = self.bind_property_with_policy(
871            widget_id,
872            AnimatedProperty::WidgetY,
873            Animation::new(base_y as f32, bump_y as f32, up_ms, Easing::OutCubic),
874            AnimationConflictPolicy::Replace,
875        )?;
876        let settle = self.bind_property_with_policy(
877            widget_id,
878            AnimatedProperty::WidgetY,
879            Animation::new(bump_y as f32, base_y as f32, settle_ms, Easing::OutBounce)
880                .with_delay(up_ms),
881            AnimationConflictPolicy::Queue,
882        )?;
883        Ok((up, settle))
884    }
885
886    pub fn bind_property_with_policy(
887        &mut self,
888        widget_id: WidgetId,
889        property: AnimatedProperty,
890        animation: Animation,
891        policy: AnimationConflictPolicy,
892    ) -> Result<AnimationId, WidgetAnimationError> {
893        match policy {
894            AnimationConflictPolicy::Ignore
895                if self
896                    .bindings
897                    .iter()
898                    .flatten()
899                    .any(|b| b.widget_id == widget_id && b.property == property) =>
900            {
901                return Err(WidgetAnimationError::ConflictIgnored);
902            }
903            AnimationConflictPolicy::Replace => {
904                let ids_to_stop: Vec<AnimationId, BINDINGS> = self
905                    .bindings
906                    .iter()
907                    .flatten()
908                    .filter(|b| b.widget_id == widget_id && b.property == property)
909                    .map(|b| b.animation_id)
910                    .collect();
911                for id in ids_to_stop {
912                    let _ = self.stop(id);
913                }
914            }
915            AnimationConflictPolicy::Queue | AnimationConflictPolicy::Ignore => {}
916        }
917
918        let animation_id = self
919            .animations
920            .start(animation)
921            .map_err(|_| WidgetAnimationError::AnimationsFull)?;
922
923        let has_existing = self
924            .bindings
925            .iter()
926            .flatten()
927            .any(|b| b.widget_id == widget_id && b.property == property);
928
929        if let Some(slot) = self.bindings.iter_mut().find(|slot| slot.is_none()) {
930            *slot = Some(Binding {
931                animation_id,
932                widget_id,
933                property,
934                last_iteration: 0,
935                queued: has_existing && policy == AnimationConflictPolicy::Queue,
936            });
937            if let Some(cb) = self.callbacks.on_start {
938                cb(animation_id, widget_id, property);
939            }
940            Ok(animation_id)
941        } else {
942            let _ = self.animations.stop(animation_id);
943            Err(WidgetAnimationError::BindingsFull)
944        }
945    }
946
947    pub fn bind_property(
948        &mut self,
949        widget_id: WidgetId,
950        property: AnimatedProperty,
951        animation: Animation,
952    ) -> Result<AnimationId, WidgetAnimationError> {
953        self.bind_property_with_policy(
954            widget_id,
955            property,
956            animation,
957            AnimationConflictPolicy::Replace,
958        )
959    }
960
961    pub fn stop(&mut self, animation_id: AnimationId) -> bool {
962        let stopped = self.animations.stop(animation_id);
963        for slot in &mut self.bindings {
964            if slot
965                .as_ref()
966                .is_some_and(|binding| binding.animation_id == animation_id)
967            {
968                *slot = None;
969            }
970        }
971        stopped
972    }
973
974    pub fn stop_widget(&mut self, widget_id: WidgetId) -> usize {
975        let ids: Vec<AnimationId, BINDINGS> = self
976            .bindings
977            .iter()
978            .flatten()
979            .filter(|b| b.widget_id == widget_id)
980            .map(|b| b.animation_id)
981            .collect();
982        let count = ids.len();
983        for id in ids {
984            let _ = self.stop(id);
985        }
986        count
987    }
988
989    pub fn stop_widget_property(
990        &mut self,
991        widget_id: WidgetId,
992        property: AnimatedProperty,
993    ) -> usize {
994        let ids: Vec<AnimationId, BINDINGS> = self
995            .bindings
996            .iter()
997            .flatten()
998            .filter(|b| b.widget_id == widget_id && b.property == property)
999            .map(|b| b.animation_id)
1000            .collect();
1001        let count = ids.len();
1002        for id in ids {
1003            let _ = self.stop(id);
1004        }
1005        count
1006    }
1007
1008    pub fn is_animating_widget(&self, widget_id: WidgetId) -> bool {
1009        self.bindings
1010            .iter()
1011            .flatten()
1012            .any(|b| b.widget_id == widget_id)
1013    }
1014
1015    pub fn is_animating_widget_property(
1016        &self,
1017        widget_id: WidgetId,
1018        property: AnimatedProperty,
1019    ) -> bool {
1020        self.bindings
1021            .iter()
1022            .flatten()
1023            .any(|b| b.widget_id == widget_id && b.property == property)
1024    }
1025
1026    pub fn handles_for_widget<const M: usize>(
1027        &self,
1028        widget_id: WidgetId,
1029        out: &mut Vec<AnimationId, M>,
1030    ) -> usize {
1031        out.clear();
1032        for binding in self
1033            .bindings
1034            .iter()
1035            .flatten()
1036            .filter(|b| b.widget_id == widget_id)
1037        {
1038            let _ = out.push(binding.animation_id);
1039        }
1040        out.len()
1041    }
1042
1043    pub fn active_bindings<const M: usize>(&self, out: &mut Vec<BindingSnapshot, M>) -> usize {
1044        out.clear();
1045        for binding in self.bindings.iter().flatten() {
1046            let _ = out.push(BindingSnapshot {
1047                animation_id: binding.animation_id,
1048                widget_id: binding.widget_id,
1049                property: binding.property,
1050                queued: binding.queued,
1051            });
1052        }
1053        out.len()
1054    }
1055
1056    pub fn tick<'a, const NODES: usize, const EVENTS: usize, const DIRTY: usize>(
1057        &mut self,
1058        dt_ms: u32,
1059        gui: &mut GuiContext<'a, NODES, EVENTS, DIRTY>,
1060    ) -> Result<(), GuiError> {
1061        for idx in 0..self.bindings.len() {
1062            let Some(binding) = self.bindings[idx] else {
1063                continue;
1064            };
1065            if binding.queued
1066                && self.bindings.iter().enumerate().any(|(other_idx, other)| {
1067                    other_idx != idx
1068                        && other.as_ref().is_some_and(|other| {
1069                            other.widget_id == binding.widget_id
1070                                && other.property == binding.property
1071                                && other.animation_id != binding.animation_id
1072                        })
1073                })
1074            {
1075                continue;
1076            }
1077
1078            let Some((value, iteration, done)) = ({
1079                if let Some(anim) = self.animations.animation_mut(binding.animation_id) {
1080                    anim.tick(dt_ms);
1081                    Some((anim.value(), anim.iteration(), anim.is_done()))
1082                } else {
1083                    None
1084                }
1085            }) else {
1086                if let Some(cb) = self.callbacks.on_complete {
1087                    cb(binding.animation_id, binding.widget_id, binding.property);
1088                }
1089                self.bindings[idx] = None;
1090                continue;
1091            };
1092
1093            if iteration > binding.last_iteration {
1094                if let Some(cb) = self.callbacks.on_repeat {
1095                    cb(binding.animation_id, binding.widget_id, binding.property);
1096                }
1097                if let Some(slot_binding) = self.bindings[idx].as_mut() {
1098                    slot_binding.last_iteration = iteration;
1099                }
1100            }
1101
1102            match binding.property {
1103                AnimatedProperty::Progress => gui.set_progress(binding.widget_id, value)?,
1104                AnimatedProperty::Meter => gui.set_meter_value(binding.widget_id, value)?,
1105                AnimatedProperty::SliderValue => gui.set_slider_value(binding.widget_id, value)?,
1106                AnimatedProperty::ScrollOffsetY => {
1107                    gui.set_scroll_offset(binding.widget_id, value.round() as i32)?
1108                }
1109                AnimatedProperty::TabSelected => {
1110                    gui.set_tab_selected(binding.widget_id, value.max(0.0).round() as usize)?
1111                }
1112                AnimatedProperty::DropdownSelected => {
1113                    gui.set_dropdown_selected(binding.widget_id, value.max(0.0).round() as usize)?
1114                }
1115                AnimatedProperty::RollerSelected => {
1116                    gui.set_roller_selected(binding.widget_id, value.max(0.0).round() as usize)?
1117                }
1118                AnimatedProperty::GaugeValue => gui.set_gauge_value(binding.widget_id, value)?,
1119                AnimatedProperty::SpinnerPhase => {
1120                    gui.set_spinner_phase(binding.widget_id, value)?
1121                }
1122                AnimatedProperty::CornerRadius => gui.set_widget_corner_radius(
1123                    binding.widget_id,
1124                    value.clamp(0.0, 255.0).round() as u8,
1125                )?,
1126                AnimatedProperty::AccentR
1127                | AnimatedProperty::AccentG
1128                | AnimatedProperty::AccentB => {
1129                    let node = gui
1130                        .widgets()
1131                        .iter()
1132                        .find(|node| node.id == binding.widget_id)
1133                        .ok_or(GuiError::NotFound)?;
1134                    let mut accent = node.style.normal.accent;
1135                    match binding.property {
1136                        AnimatedProperty::AccentR => {
1137                            accent = Rgb565::new(
1138                                value.clamp(0.0, 31.0).round() as u8,
1139                                accent.g(),
1140                                accent.b(),
1141                            );
1142                        }
1143                        AnimatedProperty::AccentG => {
1144                            accent = Rgb565::new(
1145                                accent.r(),
1146                                value.clamp(0.0, 63.0).round() as u8,
1147                                accent.b(),
1148                            );
1149                        }
1150                        AnimatedProperty::AccentB => {
1151                            accent = Rgb565::new(
1152                                accent.r(),
1153                                accent.g(),
1154                                value.clamp(0.0, 31.0).round() as u8,
1155                            );
1156                        }
1157                        _ => {}
1158                    }
1159                    gui.set_widget_accent(binding.widget_id, accent)?;
1160                }
1161                AnimatedProperty::WidgetX => {
1162                    gui.set_widget_x(binding.widget_id, value.round() as i32)?
1163                }
1164                AnimatedProperty::WidgetY => {
1165                    gui.set_widget_y(binding.widget_id, value.round() as i32)?
1166                }
1167                AnimatedProperty::WidgetWidth => {
1168                    gui.set_widget_width(binding.widget_id, value.max(1.0).round() as u32)?
1169                }
1170                AnimatedProperty::WidgetHeight => {
1171                    gui.set_widget_height(binding.widget_id, value.max(1.0).round() as u32)?
1172                }
1173                AnimatedProperty::Opacity => {
1174                    gui.set_widget_opacity(binding.widget_id, value.clamp(0.0, 255.0) as u8)?
1175                }
1176            }
1177            if done {
1178                let _ = self.animations.stop(binding.animation_id);
1179                if let Some(cb) = self.callbacks.on_complete {
1180                    cb(binding.animation_id, binding.widget_id, binding.property);
1181                }
1182                self.bindings[idx] = None;
1183            }
1184        }
1185        Ok(())
1186    }
1187
1188    pub fn active_count(&self) -> usize {
1189        self.bindings.iter().flatten().count()
1190    }
1191}
1192
1193impl From<AnimationError> for WidgetAnimationError {
1194    fn from(_: AnimationError) -> Self {
1195        Self::AnimationsFull
1196    }
1197}
1198
1199pub mod presets {
1200    use embedded_graphics_core::pixelcolor::Rgb565;
1201
1202    use super::{
1203        AnimationConflictPolicy, Easing, PathPoint, WidgetAnimationError, WidgetAnimator, WidgetId,
1204    };
1205    use crate::cinematic::{
1206        GlanceTileSpec, PeekRevealSpec, animate_glance_focus, animate_peek_reveal,
1207    };
1208
1209    pub fn entrance_fade_in_up<const TRACKS: usize, const BINDINGS: usize>(
1210        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1211        widget_id: WidgetId,
1212        from_y: i32,
1213        to_y: i32,
1214        duration_ms: u32,
1215    ) -> Result<(), WidgetAnimationError> {
1216        animator.preset_fade_in_up(widget_id, from_y, to_y, duration_ms)?;
1217        Ok(())
1218    }
1219
1220    pub fn attention_shake<const TRACKS: usize, const BINDINGS: usize>(
1221        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1222        widget_id: WidgetId,
1223        base_x: i32,
1224        amplitude: i32,
1225        duration_ms: u32,
1226    ) -> Result<(), WidgetAnimationError> {
1227        animator.preset_attention_shake(widget_id, base_x, amplitude, duration_ms)?;
1228        Ok(())
1229    }
1230
1231    pub fn selection_bump_settle<const TRACKS: usize, const BINDINGS: usize>(
1232        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1233        widget_id: WidgetId,
1234        base_y: i32,
1235        bump_px: i32,
1236        duration_ms: u32,
1237    ) -> Result<(), WidgetAnimationError> {
1238        animator.preset_selection_bump_settle(widget_id, base_y, bump_px, duration_ms)?;
1239        Ok(())
1240    }
1241
1242    pub fn style_breathe<const TRACKS: usize, const BINDINGS: usize>(
1243        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1244        widget_id: WidgetId,
1245        low_opacity: u8,
1246        high_opacity: u8,
1247        low_radius: u8,
1248        high_radius: u8,
1249        duration_ms: u32,
1250    ) -> Result<(), WidgetAnimationError> {
1251        animator.pulse_opacity(
1252            widget_id,
1253            low_opacity,
1254            high_opacity,
1255            duration_ms,
1256            Easing::InOutSine,
1257        )?;
1258        animator.animate_corner_radius(
1259            widget_id,
1260            low_radius,
1261            high_radius,
1262            duration_ms,
1263            Easing::InOutSine,
1264        )?;
1265        Ok(())
1266    }
1267
1268    pub fn style_accent_cycle<const TRACKS: usize, const BINDINGS: usize>(
1269        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1270        widget_id: WidgetId,
1271        from: Rgb565,
1272        to: Rgb565,
1273        duration_ms: u32,
1274    ) -> Result<(), WidgetAnimationError> {
1275        animator.animate_accent_color(widget_id, from, to, duration_ms, Easing::InOutSine)?;
1276        Ok(())
1277    }
1278
1279    pub fn path_float_loop<const TRACKS: usize, const BINDINGS: usize>(
1280        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1281        widget_id: WidgetId,
1282        center_x: i32,
1283        center_y: i32,
1284        radius: i32,
1285        duration_ms: u32,
1286    ) -> Result<(), WidgetAnimationError> {
1287        let points = [
1288            PathPoint::new(center_x as f32, (center_y - radius) as f32),
1289            PathPoint::new((center_x + radius) as f32, center_y as f32),
1290            PathPoint::new(center_x as f32, (center_y + radius) as f32),
1291            PathPoint::new((center_x - radius) as f32, center_y as f32),
1292            PathPoint::new(center_x as f32, (center_y - radius) as f32),
1293        ];
1294        animator.animate_widget_path(widget_id, &points, duration_ms, Easing::InOutSine)?;
1295        Ok(())
1296    }
1297
1298    pub fn orchestrate_stagger_x<const TRACKS: usize, const BINDINGS: usize>(
1299        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1300        widget_ids: &[WidgetId],
1301        from: i32,
1302        to: i32,
1303        duration_ms: u32,
1304        stagger_ms: u32,
1305    ) -> Result<usize, WidgetAnimationError> {
1306        animator.stagger_widget_x(
1307            widget_ids,
1308            from,
1309            to,
1310            duration_ms,
1311            stagger_ms,
1312            Easing::OutSine,
1313        )
1314    }
1315
1316    pub fn menu_focus_choreography<const TRACKS: usize, const BINDINGS: usize>(
1317        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1318        focused: WidgetId,
1319        base_x: i32,
1320        base_y: i32,
1321    ) -> Result<(), WidgetAnimationError> {
1322        animator.preset_selection_bump_settle(focused, base_y, 3, 120)?;
1323        animator.animate_widget_x_with_custom_interpolator(
1324            focused,
1325            base_x,
1326            base_x + 6,
1327            120,
1328            Easing::InOutSine,
1329            |from, to, t| {
1330                if t < 0.5 {
1331                    from + (to - from) * (t * 1.6)
1332                } else {
1333                    to - (to - from) * ((t - 0.5) * 1.2)
1334                }
1335            },
1336            AnimationConflictPolicy::Replace,
1337        )?;
1338        animator.animate_opacity(focused, 180, 255, 120, Easing::OutSine)?;
1339        Ok(())
1340    }
1341
1342    pub fn dialog_pop_choreography<const TRACKS: usize, const BINDINGS: usize>(
1343        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1344        dialog: WidgetId,
1345        base_y: i32,
1346    ) -> Result<(), WidgetAnimationError> {
1347        animator.preset_fade_in_up(dialog, base_y + 8, base_y, 180)?;
1348        animator.animate_corner_radius(dialog, 1, 4, 180, Easing::OutBack)?;
1349        animator.animate_opacity(dialog, 120, 255, 180, Easing::OutSine)?;
1350        Ok(())
1351    }
1352
1353    pub fn list_focus_with_neighbors<const TRACKS: usize, const BINDINGS: usize>(
1354        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1355        focused: WidgetId,
1356        neighbors: &[WidgetId],
1357        base_x: i32,
1358        base_y: i32,
1359    ) -> Result<(), WidgetAnimationError> {
1360        menu_focus_choreography(animator, focused, base_x, base_y)?;
1361        for neighbor in neighbors.iter().copied() {
1362            animator.animate_widget_x(
1363                neighbor,
1364                base_x,
1365                base_x.saturating_sub(2),
1366                120,
1367                Easing::OutSine,
1368            )?;
1369            animator.animate_opacity(neighbor, 255, 170, 120, Easing::OutSine)?;
1370        }
1371        Ok(())
1372    }
1373
1374    pub fn peek_reveal<const TRACKS: usize, const BINDINGS: usize>(
1375        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1376        icon_widget: WidgetId,
1377        title_widget: Option<WidgetId>,
1378        subtitle_widget: Option<WidgetId>,
1379        base_x: i32,
1380        base_y: i32,
1381    ) -> Result<(), WidgetAnimationError> {
1382        animate_peek_reveal(
1383            animator,
1384            icon_widget,
1385            title_widget,
1386            subtitle_widget,
1387            base_x,
1388            base_y,
1389            PeekRevealSpec::default(),
1390        )
1391    }
1392
1393    pub fn glance_focus<const TRACKS: usize, const BINDINGS: usize>(
1394        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1395        focused: WidgetId,
1396        neighbors: &[WidgetId],
1397        base_x: i32,
1398        base_y: i32,
1399    ) -> Result<(), WidgetAnimationError> {
1400        animate_glance_focus(
1401            animator,
1402            focused,
1403            neighbors,
1404            base_x,
1405            base_y,
1406            GlanceTileSpec::default(),
1407        )
1408    }
1409}