Skip to main content

astrelis_ui/
animation.rs

1//! Animation system for UI widgets.
2//!
3//! Provides smooth transitions and effects for widget properties like position, size,
4//! opacity, color, rotation, and scale.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use astrelis_ui::*;
10//!
11//! // Create an animation system
12//! let mut anim_system = AnimationSystem::new();
13//!
14//! // Animate opacity
15//! anim_system.animate(
16//!     widget_id,
17//!     Animation::new(AnimatableProperty::Opacity)
18//!         .from(0.0)
19//!         .to(1.0)
20//!         .duration(0.3)
21//!         .easing(EasingFunction::EaseInOut)
22//! );
23//!
24//! // Update animations
25//! anim_system.update(delta_time);
26//!
27//! // Apply animations to widgets
28//! anim_system.apply(&mut ui_tree);
29//! ```
30
31use crate::widget_id::WidgetId;
32use ahash::HashMap;
33
34/// Properties that can be animated.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum AnimatableProperty {
37    /// Opacity (0.0 to 1.0)
38    Opacity,
39    /// X position (alias for TranslateX for backward compatibility)
40    PositionX,
41    /// Y position (alias for TranslateY for backward compatibility)
42    PositionY,
43    /// Width
44    Width,
45    /// Height
46    Height,
47    /// Rotation in radians
48    Rotation,
49    /// X scale
50    ScaleX,
51    /// Y scale
52    ScaleY,
53    /// Red color channel (0.0 to 1.0)
54    ColorR,
55    /// Green color channel (0.0 to 1.0)
56    ColorG,
57    /// Blue color channel (0.0 to 1.0)
58    ColorB,
59    /// Alpha color channel (0.0 to 1.0)
60    ColorA,
61    /// Border radius
62    BorderRadius,
63    /// Padding
64    Padding,
65    /// Visual-only X translation (does not affect layout)
66    TranslateX,
67    /// Visual-only Y translation (does not affect layout)
68    TranslateY,
69}
70
71/// Easing functions for animations.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub enum EasingFunction {
74    /// Linear interpolation
75    Linear,
76    /// Ease in (slow start)
77    EaseIn,
78    /// Ease out (slow end)
79    EaseOut,
80    /// Ease in and out (slow start and end)
81    EaseInOut,
82    /// Bounce effect
83    Bounce,
84    /// Elastic effect
85    Elastic,
86    /// Quadratic ease in
87    QuadIn,
88    /// Quadratic ease out
89    QuadOut,
90    /// Quadratic ease in-out
91    QuadInOut,
92    /// Cubic ease in
93    CubicIn,
94    /// Cubic ease out
95    CubicOut,
96    /// Cubic ease in-out
97    CubicInOut,
98}
99
100impl EasingFunction {
101    /// Apply the easing function to a normalized time value (0.0 to 1.0).
102    pub fn apply(&self, t: f32) -> f32 {
103        let t = t.clamp(0.0, 1.0);
104
105        match self {
106            EasingFunction::Linear => t,
107            EasingFunction::EaseIn => t * t,
108            EasingFunction::EaseOut => t * (2.0 - t),
109            EasingFunction::EaseInOut => {
110                if t < 0.5 {
111                    2.0 * t * t
112                } else {
113                    -1.0 + (4.0 - 2.0 * t) * t
114                }
115            }
116            EasingFunction::Bounce => {
117                if t < 1.0 / 2.75 {
118                    7.5625 * t * t
119                } else if t < 2.0 / 2.75 {
120                    let t = t - 1.5 / 2.75;
121                    7.5625 * t * t + 0.75
122                } else if t < 2.5 / 2.75 {
123                    let t = t - 2.25 / 2.75;
124                    7.5625 * t * t + 0.9375
125                } else {
126                    let t = t - 2.625 / 2.75;
127                    7.5625 * t * t + 0.984375
128                }
129            }
130            EasingFunction::Elastic => {
131                if t == 0.0 || t == 1.0 {
132                    t
133                } else {
134                    let p = 0.3;
135                    let s = p / 4.0;
136                    let t = t - 1.0;
137                    -(2.0f32.powf(10.0 * t) * ((t - s) * (2.0 * std::f32::consts::PI) / p).sin())
138                }
139            }
140            EasingFunction::QuadIn => t * t,
141            EasingFunction::QuadOut => t * (2.0 - t),
142            EasingFunction::QuadInOut => {
143                if t < 0.5 {
144                    2.0 * t * t
145                } else {
146                    -1.0 + (4.0 - 2.0 * t) * t
147                }
148            }
149            EasingFunction::CubicIn => t * t * t,
150            EasingFunction::CubicOut => {
151                let t = t - 1.0;
152                t * t * t + 1.0
153            }
154            EasingFunction::CubicInOut => {
155                let t = t * 2.0;
156                if t < 1.0 {
157                    0.5 * t * t * t
158                } else {
159                    let t = t - 2.0;
160                    0.5 * (t * t * t + 2.0)
161                }
162            }
163        }
164    }
165}
166
167/// Animation state.
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub enum AnimationState {
170    /// Animation is running
171    Running,
172    /// Animation is paused
173    Paused,
174    /// Animation is completed
175    Completed,
176}
177
178/// An animation for a single property.
179#[derive(Debug, Clone)]
180pub struct Animation {
181    /// The property being animated
182    property: AnimatableProperty,
183    /// Start value
184    from: f32,
185    /// End value
186    to: f32,
187    /// Duration in seconds
188    duration: f32,
189    /// Elapsed time in seconds
190    elapsed: f32,
191    /// Easing function
192    easing: EasingFunction,
193    /// Animation state
194    state: AnimationState,
195    /// Loop flag
196    looping: bool,
197    /// Yoyo flag (reverse direction at end)
198    yoyo: bool,
199    /// Current direction (1.0 = forward, -1.0 = reverse)
200    direction: f32,
201    /// Delay before starting (seconds)
202    delay: f32,
203    /// Delay elapsed time
204    delay_elapsed: f32,
205}
206
207impl Animation {
208    /// Create a new animation.
209    pub fn new(property: AnimatableProperty) -> Self {
210        Self {
211            property,
212            from: 0.0,
213            to: 1.0,
214            duration: 1.0,
215            elapsed: 0.0,
216            easing: EasingFunction::Linear,
217            state: AnimationState::Running,
218            looping: false,
219            yoyo: false,
220            direction: 1.0,
221            delay: 0.0,
222            delay_elapsed: 0.0,
223        }
224    }
225
226    /// Set the start value.
227    pub fn from(mut self, value: f32) -> Self {
228        self.from = value;
229        self
230    }
231
232    /// Set the end value.
233    pub fn to(mut self, value: f32) -> Self {
234        self.to = value;
235        self
236    }
237
238    /// Set the duration in seconds.
239    pub fn duration(mut self, duration: f32) -> Self {
240        self.duration = duration;
241        self
242    }
243
244    /// Set the easing function.
245    pub fn easing(mut self, easing: EasingFunction) -> Self {
246        self.easing = easing;
247        self
248    }
249
250    /// Set looping.
251    pub fn looping(mut self, looping: bool) -> Self {
252        self.looping = looping;
253        self
254    }
255
256    /// Set yoyo (reverse at end).
257    pub fn yoyo(mut self, yoyo: bool) -> Self {
258        self.yoyo = yoyo;
259        self
260    }
261
262    /// Set delay before starting (seconds).
263    pub fn delay(mut self, delay: f32) -> Self {
264        self.delay = delay;
265        self
266    }
267
268    /// Get the property being animated.
269    pub fn property(&self) -> AnimatableProperty {
270        self.property
271    }
272
273    /// Get the current value.
274    pub fn value(&self) -> f32 {
275        // If we haven't started due to delay, return from value
276        if self.delay_elapsed < self.delay {
277            return self.from;
278        }
279
280        let t = (self.elapsed / self.duration).clamp(0.0, 1.0);
281        let eased_t = self.easing.apply(t);
282
283        // For both forward and reverse, interpolate based on elapsed time
284        self.from + (self.to - self.from) * eased_t
285    }
286
287    /// Get the current state.
288    pub fn state(&self) -> AnimationState {
289        self.state
290    }
291
292    /// Pause the animation.
293    pub fn pause(&mut self) {
294        self.state = AnimationState::Paused;
295    }
296
297    /// Resume the animation.
298    pub fn resume(&mut self) {
299        if self.state == AnimationState::Paused {
300            self.state = AnimationState::Running;
301        }
302    }
303
304    /// Reset the animation.
305    pub fn reset(&mut self) {
306        self.elapsed = 0.0;
307        self.delay_elapsed = 0.0;
308        self.state = AnimationState::Running;
309        self.direction = 1.0;
310    }
311
312    /// Update the animation by delta time.
313    ///
314    /// Returns true if the animation is still running.
315    pub fn update(&mut self, delta_time: f32) -> bool {
316        if self.state != AnimationState::Running {
317            return self.state != AnimationState::Completed;
318        }
319
320        // Handle delay
321        if self.delay_elapsed < self.delay {
322            self.delay_elapsed += delta_time;
323            if self.delay_elapsed < self.delay {
324                return true;
325            }
326            // Continue with animation after delay
327            let _ = delta_time - (self.delay - self.delay_elapsed);
328        }
329
330        self.elapsed += delta_time * self.direction;
331
332        if self.direction > 0.0 && self.elapsed >= self.duration {
333            if self.yoyo {
334                self.direction = -1.0;
335                self.elapsed = self.duration;
336            } else if self.looping {
337                self.elapsed = 0.0;
338            } else {
339                self.elapsed = self.duration;
340                self.state = AnimationState::Completed;
341                return false;
342            }
343        } else if self.direction < 0.0 && self.elapsed <= 0.0 {
344            if self.looping {
345                self.direction = 1.0;
346                self.elapsed = 0.0;
347            } else {
348                self.elapsed = 0.0;
349                self.state = AnimationState::Completed;
350                return false;
351            }
352        }
353
354        true
355    }
356}
357
358/// A collection of animations for a single widget.
359#[derive(Debug, Clone)]
360pub struct WidgetAnimations {
361    /// Animations for different properties
362    animations: HashMap<AnimatableProperty, Animation>,
363}
364
365impl WidgetAnimations {
366    /// Create a new widget animations collection.
367    pub fn new() -> Self {
368        Self {
369            animations: HashMap::default(),
370        }
371    }
372
373    /// Add an animation.
374    pub fn add(&mut self, animation: Animation) {
375        self.animations.insert(animation.property(), animation);
376    }
377
378    /// Remove an animation.
379    pub fn remove(&mut self, property: AnimatableProperty) {
380        self.animations.remove(&property);
381    }
382
383    /// Get an animation.
384    pub fn get(&self, property: AnimatableProperty) -> Option<&Animation> {
385        self.animations.get(&property)
386    }
387
388    /// Get a mutable animation.
389    pub fn get_mut(&mut self, property: AnimatableProperty) -> Option<&mut Animation> {
390        self.animations.get_mut(&property)
391    }
392
393    /// Update all animations.
394    ///
395    /// Returns true if any animations are still running.
396    pub fn update(&mut self, delta_time: f32) -> bool {
397        let mut any_running = false;
398
399        self.animations.retain(|_, animation| {
400            let running = animation.update(delta_time);
401            any_running |= running;
402            running
403        });
404
405        any_running
406    }
407
408    /// Get all current animation values.
409    pub fn values(&self) -> HashMap<AnimatableProperty, f32> {
410        self.animations
411            .iter()
412            .map(|(prop, anim)| (*prop, anim.value()))
413            .collect()
414    }
415
416    /// Check if there are any animations.
417    pub fn is_empty(&self) -> bool {
418        self.animations.is_empty()
419    }
420
421    /// Clear all animations.
422    pub fn clear(&mut self) {
423        self.animations.clear();
424    }
425}
426
427impl Default for WidgetAnimations {
428    fn default() -> Self {
429        Self::new()
430    }
431}
432
433/// Animation system for managing widget animations.
434pub struct AnimationSystem {
435    /// Animations per widget
436    widget_animations: HashMap<WidgetId, WidgetAnimations>,
437}
438
439impl AnimationSystem {
440    /// Create a new animation system.
441    pub fn new() -> Self {
442        Self {
443            widget_animations: HashMap::default(),
444        }
445    }
446
447    /// Add an animation for a widget.
448    pub fn animate(&mut self, widget_id: WidgetId, animation: Animation) {
449        self.widget_animations
450            .entry(widget_id)
451            .or_default()
452            .add(animation);
453    }
454
455    /// Remove an animation for a widget.
456    pub fn remove_animation(&mut self, widget_id: WidgetId, property: AnimatableProperty) {
457        if let Some(animations) = self.widget_animations.get_mut(&widget_id) {
458            animations.remove(property);
459            if animations.is_empty() {
460                self.widget_animations.remove(&widget_id);
461            }
462        }
463    }
464
465    /// Remove all animations for a widget.
466    pub fn remove_widget_animations(&mut self, widget_id: WidgetId) {
467        self.widget_animations.remove(&widget_id);
468    }
469
470    /// Get animations for a widget.
471    pub fn get_animations(&self, widget_id: WidgetId) -> Option<&WidgetAnimations> {
472        self.widget_animations.get(&widget_id)
473    }
474
475    /// Get mutable animations for a widget.
476    pub fn get_animations_mut(&mut self, widget_id: WidgetId) -> Option<&mut WidgetAnimations> {
477        self.widget_animations.get_mut(&widget_id)
478    }
479
480    /// Update all animations by delta time.
481    pub fn update(&mut self, delta_time: f32) {
482        self.widget_animations.retain(|_, animations| {
483            animations.update(delta_time);
484            !animations.is_empty()
485        });
486    }
487
488    /// Get all animated widget values.
489    ///
490    /// Returns a map of widget IDs to their animated property values.
491    pub fn animated_values(&self) -> HashMap<WidgetId, HashMap<AnimatableProperty, f32>> {
492        self.widget_animations
493            .iter()
494            .map(|(id, animations)| (*id, animations.values()))
495            .collect()
496    }
497
498    /// Clear all animations.
499    pub fn clear(&mut self) {
500        self.widget_animations.clear();
501    }
502
503    /// Get the number of animated widgets.
504    pub fn widget_count(&self) -> usize {
505        self.widget_animations.len()
506    }
507
508    /// Returns true if any animations are currently running.
509    pub fn has_active(&self) -> bool {
510        !self.widget_animations.is_empty()
511    }
512
513    /// Tick all animations by delta time and apply values to the UI.
514    ///
515    /// This combines `update()` with automatic application of animated values
516    /// to the UI system via `update_opacity`, `update_translate_x/y`, `update_scale_x/y`.
517    ///
518    /// Call once per frame in the update loop:
519    /// ```ignore
520    /// fn update(&mut self, ctx: &mut AppCtx) {
521    ///     let dt = ctx.delta_time();
522    ///     self.animations.tick(dt, &mut self.ui);
523    /// }
524    /// ```
525    pub fn tick(&mut self, delta_time: f32, ui: &mut crate::UiCore) {
526        self.widget_animations.retain(|&widget_id, animations| {
527            animations.update(delta_time);
528
529            // Apply current values to the UI
530            for (prop, value) in animations.values() {
531                match prop {
532                    AnimatableProperty::Opacity => {
533                        ui.update_opacity(widget_id, value);
534                    }
535                    AnimatableProperty::TranslateX | AnimatableProperty::PositionX => {
536                        ui.update_translate_x(widget_id, value);
537                    }
538                    AnimatableProperty::TranslateY | AnimatableProperty::PositionY => {
539                        ui.update_translate_y(widget_id, value);
540                    }
541                    AnimatableProperty::ScaleX => {
542                        ui.update_scale_x(widget_id, value);
543                    }
544                    AnimatableProperty::ScaleY => {
545                        ui.update_scale_y(widget_id, value);
546                    }
547                    // Other properties don't have direct update_ methods yet
548                    _ => {}
549                }
550            }
551
552            !animations.is_empty()
553        });
554    }
555
556    /// Tick all animations by delta time and apply values to the UI system.
557    ///
558    /// Convenience method that works with `UiSystem` instead of `UiCore`.
559    pub fn tick_system(&mut self, delta_time: f32, ui: &mut crate::UiSystem) {
560        self.tick(delta_time, ui.core_mut());
561    }
562}
563
564impl Default for AnimationSystem {
565    fn default() -> Self {
566        Self::new()
567    }
568}
569
570/// Helper function to create a fade-in animation.
571pub fn fade_in(duration: f32) -> Animation {
572    Animation::new(AnimatableProperty::Opacity)
573        .from(0.0)
574        .to(1.0)
575        .duration(duration)
576        .easing(EasingFunction::EaseInOut)
577}
578
579/// Helper function to create a fade-out animation.
580pub fn fade_out(duration: f32) -> Animation {
581    Animation::new(AnimatableProperty::Opacity)
582        .from(1.0)
583        .to(0.0)
584        .duration(duration)
585        .easing(EasingFunction::EaseInOut)
586}
587
588/// Helper function to create a slide-in animation from the left.
589pub fn slide_in_left(from_x: f32, to_x: f32, duration: f32) -> Animation {
590    Animation::new(AnimatableProperty::PositionX)
591        .from(from_x)
592        .to(to_x)
593        .duration(duration)
594        .easing(EasingFunction::EaseOut)
595}
596
597/// Helper function to create a slide-in animation from the top.
598pub fn slide_in_top(from_y: f32, to_y: f32, duration: f32) -> Animation {
599    Animation::new(AnimatableProperty::PositionY)
600        .from(from_y)
601        .to(to_y)
602        .duration(duration)
603        .easing(EasingFunction::EaseOut)
604}
605
606/// Helper function to create a scale animation.
607pub fn scale(from: f32, to: f32, duration: f32) -> Animation {
608    Animation::new(AnimatableProperty::ScaleX)
609        .from(from)
610        .to(to)
611        .duration(duration)
612        .easing(EasingFunction::EaseInOut)
613}
614
615/// Helper function to create a bounce animation.
616pub fn bounce(duration: f32) -> Animation {
617    Animation::new(AnimatableProperty::ScaleY)
618        .from(1.0)
619        .to(1.2)
620        .duration(duration)
621        .easing(EasingFunction::Bounce)
622        .yoyo(true)
623        .looping(true)
624}
625
626/// Helper function to create a translate-X animation (visual-only slide).
627pub fn translate_x(from: f32, to: f32, duration: f32) -> Animation {
628    Animation::new(AnimatableProperty::TranslateX)
629        .from(from)
630        .to(to)
631        .duration(duration)
632        .easing(EasingFunction::EaseOut)
633}
634
635/// Helper function to create a translate-Y animation (visual-only slide).
636pub fn translate_y(from: f32, to: f32, duration: f32) -> Animation {
637    Animation::new(AnimatableProperty::TranslateY)
638        .from(from)
639        .to(to)
640        .duration(duration)
641        .easing(EasingFunction::EaseOut)
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn test_linear_easing() {
650        let easing = EasingFunction::Linear;
651        assert_eq!(easing.apply(0.0), 0.0);
652        assert_eq!(easing.apply(0.5), 0.5);
653        assert_eq!(easing.apply(1.0), 1.0);
654    }
655
656    #[test]
657    fn test_animation_update() {
658        let mut anim = Animation::new(AnimatableProperty::Opacity)
659            .from(0.0)
660            .to(1.0)
661            .duration(1.0);
662
663        // At start
664        assert_eq!(anim.value(), 0.0);
665
666        // Halfway
667        assert!(anim.update(0.5));
668        assert!((anim.value() - 0.5).abs() < 0.01);
669
670        // Complete - should return false when done
671        assert!(!anim.update(0.5));
672        assert_eq!(anim.value(), 1.0);
673        assert_eq!(anim.state(), AnimationState::Completed);
674    }
675
676    #[test]
677    fn test_animation_system() {
678        let mut system = AnimationSystem::new();
679        let widget_id = WidgetId::new("test_widget");
680
681        system.animate(widget_id, fade_in(1.0));
682
683        assert_eq!(system.widget_count(), 1);
684
685        system.update(1.0);
686
687        // Animation should be complete and removed
688        assert_eq!(system.widget_count(), 0);
689    }
690
691    #[test]
692    fn test_looping_animation() {
693        let mut anim = Animation::new(AnimatableProperty::Opacity)
694            .from(0.0)
695            .to(1.0)
696            .duration(1.0)
697            .looping(true);
698
699        // First loop
700        anim.update(1.0);
701        assert_eq!(anim.state(), AnimationState::Running);
702        assert_eq!(anim.value(), 0.0); // Should loop back
703
704        // Second loop
705        anim.update(0.5);
706        assert!((anim.value() - 0.5).abs() < 0.01);
707    }
708
709    #[test]
710    fn test_yoyo_animation() {
711        let mut anim = Animation::new(AnimatableProperty::Opacity)
712            .from(0.0)
713            .to(1.0)
714            .duration(1.0)
715            .yoyo(true);
716
717        // Forward - should still be running after first direction
718        assert!(anim.update(1.0));
719        assert!((anim.value() - 1.0).abs() < 0.01);
720        assert_eq!(anim.state(), AnimationState::Running);
721
722        // Reverse - should complete and return false
723        assert!(!anim.update(1.0));
724        assert!((anim.value() - 0.0).abs() < 0.01);
725        assert_eq!(anim.state(), AnimationState::Completed);
726    }
727
728    #[test]
729    fn test_translate_animation_helpers() {
730        let anim_x = translate_x(-100.0, 0.0, 0.5);
731        assert_eq!(anim_x.property(), AnimatableProperty::TranslateX);
732        assert_eq!(anim_x.value(), -100.0); // At start
733
734        let anim_y = translate_y(-50.0, 0.0, 0.3);
735        assert_eq!(anim_y.property(), AnimatableProperty::TranslateY);
736        assert_eq!(anim_y.value(), -50.0); // At start
737    }
738
739    #[test]
740    fn test_has_active() {
741        let mut system = AnimationSystem::new();
742        assert!(!system.has_active());
743
744        let widget_id = WidgetId::new("test");
745        system.animate(widget_id, fade_in(1.0));
746        assert!(system.has_active());
747
748        system.update(2.0); // Complete the animation
749        assert!(!system.has_active());
750    }
751
752    #[test]
753    fn test_easing_boundary_values() {
754        // All easing functions should produce 0 at t=0 and 1 at t=1
755        let easings = [
756            EasingFunction::Linear,
757            EasingFunction::EaseIn,
758            EasingFunction::EaseOut,
759            EasingFunction::EaseInOut,
760            EasingFunction::QuadIn,
761            EasingFunction::QuadOut,
762            EasingFunction::QuadInOut,
763            EasingFunction::CubicIn,
764            EasingFunction::CubicOut,
765            EasingFunction::CubicInOut,
766        ];
767        for easing in &easings {
768            assert!(
769                (easing.apply(0.0) - 0.0).abs() < 0.001,
770                "{easing:?} failed at t=0"
771            );
772            assert!(
773                (easing.apply(1.0) - 1.0).abs() < 0.001,
774                "{easing:?} failed at t=1"
775            );
776        }
777    }
778}