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                #[cfg(feature = "rich-widgets")]
1105                AnimatedProperty::Meter => gui.set_meter_value(binding.widget_id, value)?,
1106                #[cfg(feature = "rich-widgets")]
1107                AnimatedProperty::SliderValue => gui.set_slider_value(binding.widget_id, value)?,
1108                #[cfg(feature = "rich-widgets")]
1109                AnimatedProperty::ScrollOffsetY => {
1110                    gui.set_scroll_offset(binding.widget_id, value.round() as i32)?
1111                }
1112                #[cfg(feature = "rich-widgets")]
1113                AnimatedProperty::TabSelected => {
1114                    gui.set_tab_selected(binding.widget_id, value.max(0.0).round() as usize)?
1115                }
1116                #[cfg(feature = "rich-widgets")]
1117                AnimatedProperty::DropdownSelected => {
1118                    gui.set_dropdown_selected(binding.widget_id, value.max(0.0).round() as usize)?
1119                }
1120                #[cfg(feature = "rich-widgets")]
1121                AnimatedProperty::RollerSelected => {
1122                    gui.set_roller_selected(binding.widget_id, value.max(0.0).round() as usize)?
1123                }
1124                #[cfg(feature = "rich-widgets")]
1125                AnimatedProperty::GaugeValue => gui.set_gauge_value(binding.widget_id, value)?,
1126                #[cfg(feature = "rich-widgets")]
1127                AnimatedProperty::SpinnerPhase => {
1128                    gui.set_spinner_phase(binding.widget_id, value)?
1129                }
1130                AnimatedProperty::CornerRadius => gui.set_widget_corner_radius(
1131                    binding.widget_id,
1132                    value.clamp(0.0, 255.0).round() as u8,
1133                )?,
1134                AnimatedProperty::AccentR
1135                | AnimatedProperty::AccentG
1136                | AnimatedProperty::AccentB => {
1137                    let node = gui
1138                        .widgets()
1139                        .iter()
1140                        .find(|node| node.id == binding.widget_id)
1141                        .ok_or(GuiError::NotFound)?;
1142                    let mut accent = node.style.normal.accent;
1143                    match binding.property {
1144                        AnimatedProperty::AccentR => {
1145                            accent = Rgb565::new(
1146                                value.clamp(0.0, 31.0).round() as u8,
1147                                accent.g(),
1148                                accent.b(),
1149                            );
1150                        }
1151                        AnimatedProperty::AccentG => {
1152                            accent = Rgb565::new(
1153                                accent.r(),
1154                                value.clamp(0.0, 63.0).round() as u8,
1155                                accent.b(),
1156                            );
1157                        }
1158                        AnimatedProperty::AccentB => {
1159                            accent = Rgb565::new(
1160                                accent.r(),
1161                                accent.g(),
1162                                value.clamp(0.0, 31.0).round() as u8,
1163                            );
1164                        }
1165                        _ => {}
1166                    }
1167                    gui.set_widget_accent(binding.widget_id, accent)?;
1168                }
1169                AnimatedProperty::WidgetX => {
1170                    gui.set_widget_x(binding.widget_id, value.round() as i32)?
1171                }
1172                AnimatedProperty::WidgetY => {
1173                    gui.set_widget_y(binding.widget_id, value.round() as i32)?
1174                }
1175                AnimatedProperty::WidgetWidth => {
1176                    gui.set_widget_width(binding.widget_id, value.max(1.0).round() as u32)?
1177                }
1178                AnimatedProperty::WidgetHeight => {
1179                    gui.set_widget_height(binding.widget_id, value.max(1.0).round() as u32)?
1180                }
1181                AnimatedProperty::Opacity => {
1182                    gui.set_widget_opacity(binding.widget_id, value.clamp(0.0, 255.0) as u8)?
1183                }
1184                // Reached only when `rich-widgets` is disabled and this
1185                // binding targets a gated property (Meter/SliderValue/...);
1186                // there's no setter to call, so just leave the value as-is.
1187                #[cfg(not(feature = "rich-widgets"))]
1188                _ => {}
1189            }
1190            if done {
1191                let _ = self.animations.stop(binding.animation_id);
1192                if let Some(cb) = self.callbacks.on_complete {
1193                    cb(binding.animation_id, binding.widget_id, binding.property);
1194                }
1195                self.bindings[idx] = None;
1196            }
1197        }
1198        Ok(())
1199    }
1200
1201    pub fn active_count(&self) -> usize {
1202        self.bindings.iter().flatten().count()
1203    }
1204}
1205
1206impl From<AnimationError> for WidgetAnimationError {
1207    fn from(_: AnimationError) -> Self {
1208        Self::AnimationsFull
1209    }
1210}
1211
1212pub mod presets {
1213    use embedded_graphics_core::pixelcolor::Rgb565;
1214
1215    use super::{
1216        AnimationConflictPolicy, Easing, PathPoint, WidgetAnimationError, WidgetAnimator, WidgetId,
1217    };
1218    use crate::cinematic::{
1219        GlanceTileSpec, PeekRevealSpec, animate_glance_focus, animate_peek_reveal,
1220    };
1221
1222    pub fn entrance_fade_in_up<const TRACKS: usize, const BINDINGS: usize>(
1223        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1224        widget_id: WidgetId,
1225        from_y: i32,
1226        to_y: i32,
1227        duration_ms: u32,
1228    ) -> Result<(), WidgetAnimationError> {
1229        animator.preset_fade_in_up(widget_id, from_y, to_y, duration_ms)?;
1230        Ok(())
1231    }
1232
1233    pub fn attention_shake<const TRACKS: usize, const BINDINGS: usize>(
1234        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1235        widget_id: WidgetId,
1236        base_x: i32,
1237        amplitude: i32,
1238        duration_ms: u32,
1239    ) -> Result<(), WidgetAnimationError> {
1240        animator.preset_attention_shake(widget_id, base_x, amplitude, duration_ms)?;
1241        Ok(())
1242    }
1243
1244    pub fn selection_bump_settle<const TRACKS: usize, const BINDINGS: usize>(
1245        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1246        widget_id: WidgetId,
1247        base_y: i32,
1248        bump_px: i32,
1249        duration_ms: u32,
1250    ) -> Result<(), WidgetAnimationError> {
1251        animator.preset_selection_bump_settle(widget_id, base_y, bump_px, duration_ms)?;
1252        Ok(())
1253    }
1254
1255    pub fn style_breathe<const TRACKS: usize, const BINDINGS: usize>(
1256        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1257        widget_id: WidgetId,
1258        low_opacity: u8,
1259        high_opacity: u8,
1260        low_radius: u8,
1261        high_radius: u8,
1262        duration_ms: u32,
1263    ) -> Result<(), WidgetAnimationError> {
1264        animator.pulse_opacity(
1265            widget_id,
1266            low_opacity,
1267            high_opacity,
1268            duration_ms,
1269            Easing::InOutSine,
1270        )?;
1271        animator.animate_corner_radius(
1272            widget_id,
1273            low_radius,
1274            high_radius,
1275            duration_ms,
1276            Easing::InOutSine,
1277        )?;
1278        Ok(())
1279    }
1280
1281    pub fn style_accent_cycle<const TRACKS: usize, const BINDINGS: usize>(
1282        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1283        widget_id: WidgetId,
1284        from: Rgb565,
1285        to: Rgb565,
1286        duration_ms: u32,
1287    ) -> Result<(), WidgetAnimationError> {
1288        animator.animate_accent_color(widget_id, from, to, duration_ms, Easing::InOutSine)?;
1289        Ok(())
1290    }
1291
1292    pub fn path_float_loop<const TRACKS: usize, const BINDINGS: usize>(
1293        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1294        widget_id: WidgetId,
1295        center_x: i32,
1296        center_y: i32,
1297        radius: i32,
1298        duration_ms: u32,
1299    ) -> Result<(), WidgetAnimationError> {
1300        let points = [
1301            PathPoint::new(center_x as f32, (center_y - radius) as f32),
1302            PathPoint::new((center_x + radius) as f32, center_y as f32),
1303            PathPoint::new(center_x as f32, (center_y + radius) as f32),
1304            PathPoint::new((center_x - radius) as f32, center_y as f32),
1305            PathPoint::new(center_x as f32, (center_y - radius) as f32),
1306        ];
1307        animator.animate_widget_path(widget_id, &points, duration_ms, Easing::InOutSine)?;
1308        Ok(())
1309    }
1310
1311    pub fn orchestrate_stagger_x<const TRACKS: usize, const BINDINGS: usize>(
1312        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1313        widget_ids: &[WidgetId],
1314        from: i32,
1315        to: i32,
1316        duration_ms: u32,
1317        stagger_ms: u32,
1318    ) -> Result<usize, WidgetAnimationError> {
1319        animator.stagger_widget_x(
1320            widget_ids,
1321            from,
1322            to,
1323            duration_ms,
1324            stagger_ms,
1325            Easing::OutSine,
1326        )
1327    }
1328
1329    pub fn menu_focus_choreography<const TRACKS: usize, const BINDINGS: usize>(
1330        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1331        focused: WidgetId,
1332        base_x: i32,
1333        base_y: i32,
1334    ) -> Result<(), WidgetAnimationError> {
1335        animator.preset_selection_bump_settle(focused, base_y, 3, 120)?;
1336        animator.animate_widget_x_with_custom_interpolator(
1337            focused,
1338            base_x,
1339            base_x + 6,
1340            120,
1341            Easing::InOutSine,
1342            |from, to, t| {
1343                if t < 0.5 {
1344                    from + (to - from) * (t * 1.6)
1345                } else {
1346                    to - (to - from) * ((t - 0.5) * 1.2)
1347                }
1348            },
1349            AnimationConflictPolicy::Replace,
1350        )?;
1351        animator.animate_opacity(focused, 180, 255, 120, Easing::OutSine)?;
1352        Ok(())
1353    }
1354
1355    pub fn dialog_pop_choreography<const TRACKS: usize, const BINDINGS: usize>(
1356        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1357        dialog: WidgetId,
1358        base_y: i32,
1359    ) -> Result<(), WidgetAnimationError> {
1360        animator.preset_fade_in_up(dialog, base_y + 8, base_y, 180)?;
1361        animator.animate_corner_radius(dialog, 1, 4, 180, Easing::OutBack)?;
1362        animator.animate_opacity(dialog, 120, 255, 180, Easing::OutSine)?;
1363        Ok(())
1364    }
1365
1366    pub fn list_focus_with_neighbors<const TRACKS: usize, const BINDINGS: usize>(
1367        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1368        focused: WidgetId,
1369        neighbors: &[WidgetId],
1370        base_x: i32,
1371        base_y: i32,
1372    ) -> Result<(), WidgetAnimationError> {
1373        menu_focus_choreography(animator, focused, base_x, base_y)?;
1374        for neighbor in neighbors.iter().copied() {
1375            animator.animate_widget_x(
1376                neighbor,
1377                base_x,
1378                base_x.saturating_sub(2),
1379                120,
1380                Easing::OutSine,
1381            )?;
1382            animator.animate_opacity(neighbor, 255, 170, 120, Easing::OutSine)?;
1383        }
1384        Ok(())
1385    }
1386
1387    pub fn peek_reveal<const TRACKS: usize, const BINDINGS: usize>(
1388        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1389        icon_widget: WidgetId,
1390        title_widget: Option<WidgetId>,
1391        subtitle_widget: Option<WidgetId>,
1392        base_x: i32,
1393        base_y: i32,
1394    ) -> Result<(), WidgetAnimationError> {
1395        animate_peek_reveal(
1396            animator,
1397            icon_widget,
1398            title_widget,
1399            subtitle_widget,
1400            base_x,
1401            base_y,
1402            PeekRevealSpec::default(),
1403        )
1404    }
1405
1406    pub fn glance_focus<const TRACKS: usize, const BINDINGS: usize>(
1407        animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
1408        focused: WidgetId,
1409        neighbors: &[WidgetId],
1410        base_x: i32,
1411        base_y: i32,
1412    ) -> Result<(), WidgetAnimationError> {
1413        animate_glance_focus(
1414            animator,
1415            focused,
1416            neighbors,
1417            base_x,
1418            base_y,
1419            GlanceTileSpec::default(),
1420        )
1421    }
1422}