Skip to main content

ftui_widgets/modal/
animation.rs

1#![forbid(unsafe_code)]
2
3//! Modal animation system for entrance, exit, and backdrop transitions.
4//!
5//! This module provides:
6//! - Scale-in / scale-out animations for modal content
7//! - Backdrop fade animations
8//! - Animation cancellation on rapid open/close
9//! - Reduced motion support
10//!
11//! # Example
12//!
13//! ```ignore
14//! let config = ModalAnimationConfig::default();
15//! let mut state = ModalAnimationState::new();
16//!
17//! // Start opening animation
18//! state.start_opening();
19//!
20//! // Each frame, update and check progress
21//! state.tick(delta_time);
22//! let (scale, opacity, backdrop_opacity) = state.current_values(&config);
23//! ```
24//!
25//! # Invariants
26//!
27//! - Animation progress is always in [0.0, 1.0]
28//! - Scale factor is always in [min_scale, 1.0] during animation
29//! - Opacity is always in [0.0, 1.0]
30//! - Rapid open/close cancels in-flight animations properly
31//!
32//! # Failure Modes
33//!
34//! - If delta_time is negative, it's clamped to 0
35//! - Zero-duration animations complete instantly
36
37use std::time::Duration;
38
39// ============================================================================
40// Animation Phase
41// ============================================================================
42
43/// Current phase of the modal animation lifecycle.
44///
45/// State machine: Closed → Opening → Open → Closing → Closed
46///
47/// Rapid toggling can skip phases (e.g., Opening → Closing directly).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum ModalAnimationPhase {
50    /// Modal is fully closed and invisible.
51    #[default]
52    Closed,
53    /// Modal is animating in (scale-up, fade-in).
54    Opening,
55    /// Modal is fully open and visible.
56    Open,
57    /// Modal is animating out (scale-down, fade-out).
58    Closing,
59}
60
61impl ModalAnimationPhase {
62    /// Check if the modal should be rendered.
63    pub fn is_visible(self) -> bool {
64        !matches!(self, Self::Closed)
65    }
66
67    /// Check if animation is in progress.
68    pub fn is_animating(self) -> bool {
69        matches!(self, Self::Opening | Self::Closing)
70    }
71}
72
73// ============================================================================
74// Entrance Animation Types
75// ============================================================================
76
77/// Entrance animation type for modal content.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum ModalEntranceAnimation {
80    /// Scale up from center (classic modal pop).
81    #[default]
82    ScaleIn,
83    /// Fade in (opacity only, no scale).
84    FadeIn,
85    /// Slide down from top with fade.
86    SlideDown,
87    /// Slide up from bottom with fade.
88    SlideUp,
89    /// No animation (instant appear).
90    None,
91}
92
93impl ModalEntranceAnimation {
94    /// Get the initial scale factor for this animation.
95    ///
96    /// Returns a scale in [0.0, 1.0] where 1.0 = full size.
97    pub fn initial_scale(self, config: &ModalAnimationConfig) -> f64 {
98        match self {
99            Self::ScaleIn => config.min_scale,
100            Self::FadeIn | Self::SlideDown | Self::SlideUp | Self::None => 1.0,
101        }
102    }
103
104    /// Get the initial opacity for this animation.
105    pub fn initial_opacity(self) -> f64 {
106        match self {
107            Self::ScaleIn | Self::FadeIn | Self::SlideDown | Self::SlideUp => 0.0,
108            Self::None => 1.0,
109        }
110    }
111
112    /// Get the initial Y offset in cells for this animation.
113    pub fn initial_y_offset(self, modal_height: u16) -> i16 {
114        match self {
115            Self::SlideDown => -(modal_height as i16).min(8),
116            Self::SlideUp => (modal_height as i16).min(8),
117            Self::ScaleIn | Self::FadeIn | Self::None => 0,
118        }
119    }
120
121    /// Calculate scale at a given eased progress (0.0 to 1.0).
122    pub fn scale_at_progress(self, progress: f64, config: &ModalAnimationConfig) -> f64 {
123        let initial = self.initial_scale(config);
124        let p = progress.clamp(0.0, 1.0);
125        initial + (1.0 - initial) * p
126    }
127
128    /// Calculate opacity at a given eased progress (0.0 to 1.0).
129    pub fn opacity_at_progress(self, progress: f64) -> f64 {
130        let initial = self.initial_opacity();
131        let p = progress.clamp(0.0, 1.0);
132        initial + (1.0 - initial) * p
133    }
134
135    /// Calculate Y offset at a given eased progress (0.0 to 1.0).
136    pub fn y_offset_at_progress(self, progress: f64, modal_height: u16) -> i16 {
137        let initial = self.initial_y_offset(modal_height);
138        let p = progress.clamp(0.0, 1.0);
139        let inv = 1.0 - p;
140        (initial as f64 * inv).round() as i16
141    }
142}
143
144// ============================================================================
145// Exit Animation Types
146// ============================================================================
147
148/// Exit animation type for modal content.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ModalExitAnimation {
151    /// Scale down to center (reverse of ScaleIn).
152    #[default]
153    ScaleOut,
154    /// Fade out (opacity only, no scale).
155    FadeOut,
156    /// Slide up with fade.
157    SlideUp,
158    /// Slide down with fade.
159    SlideDown,
160    /// No animation (instant disappear).
161    None,
162}
163
164impl ModalExitAnimation {
165    /// Get the final scale factor for this animation.
166    pub fn final_scale(self, config: &ModalAnimationConfig) -> f64 {
167        match self {
168            Self::ScaleOut => config.min_scale,
169            Self::FadeOut | Self::SlideUp | Self::SlideDown | Self::None => 1.0,
170        }
171    }
172
173    /// Get the final opacity for this animation.
174    pub fn final_opacity(self) -> f64 {
175        match self {
176            Self::ScaleOut | Self::FadeOut | Self::SlideUp | Self::SlideDown => 0.0,
177            Self::None => 0.0, // Still 0 because modal is closing
178        }
179    }
180
181    /// Get the final Y offset in cells for this animation.
182    pub fn final_y_offset(self, modal_height: u16) -> i16 {
183        match self {
184            Self::SlideUp => -(modal_height as i16).min(8),
185            Self::SlideDown => (modal_height as i16).min(8),
186            Self::ScaleOut | Self::FadeOut | Self::None => 0,
187        }
188    }
189
190    /// Calculate scale at a given eased progress (0.0 to 1.0).
191    ///
192    /// Progress 0.0 = full size, 1.0 = final (shrunken).
193    pub fn scale_at_progress(self, progress: f64, config: &ModalAnimationConfig) -> f64 {
194        let final_scale = self.final_scale(config);
195        let p = progress.clamp(0.0, 1.0);
196        1.0 - (1.0 - final_scale) * p
197    }
198
199    /// Calculate opacity at a given eased progress (0.0 to 1.0).
200    pub fn opacity_at_progress(self, progress: f64) -> f64 {
201        let p = progress.clamp(0.0, 1.0);
202        1.0 - p
203    }
204
205    /// Calculate Y offset at a given eased progress (0.0 to 1.0).
206    pub fn y_offset_at_progress(self, progress: f64, modal_height: u16) -> i16 {
207        let final_offset = self.final_y_offset(modal_height);
208        let p = progress.clamp(0.0, 1.0);
209        (final_offset as f64 * p).round() as i16
210    }
211}
212
213// ============================================================================
214// Easing Functions
215// ============================================================================
216
217/// Easing function for modal animations.
218///
219/// Simplified subset of easing curves for modal animations.
220/// For the full set, see `ftui_extras::text_effects::Easing`.
221#[derive(Debug, Clone, Copy, PartialEq, Default)]
222pub enum ModalEasing {
223    /// Linear interpolation.
224    Linear,
225    /// Smooth ease-out (decelerating) - good for entrances.
226    #[default]
227    EaseOut,
228    /// Smooth ease-in (accelerating) - good for exits.
229    EaseIn,
230    /// Smooth S-curve - good for general transitions.
231    EaseInOut,
232    /// Slight overshoot then settle - bouncy feel.
233    Back,
234}
235
236impl ModalEasing {
237    /// Apply the easing function to a progress value (0.0 to 1.0).
238    pub fn apply(self, t: f64) -> f64 {
239        let t = t.clamp(0.0, 1.0);
240        match self {
241            Self::Linear => t,
242            Self::EaseOut => {
243                let inv = 1.0 - t;
244                1.0 - inv * inv * inv
245            }
246            Self::EaseIn => t * t * t,
247            Self::EaseInOut => {
248                if t < 0.5 {
249                    4.0 * t * t * t
250                } else {
251                    let inv = -2.0 * t + 2.0;
252                    1.0 - inv * inv * inv / 2.0
253                }
254            }
255            Self::Back => {
256                // Back ease-out: slight overshoot then settle
257                let c1 = 1.70158;
258                let c3 = c1 + 1.0;
259                let t_minus_1 = t - 1.0;
260                1.0 + c3 * t_minus_1 * t_minus_1 * t_minus_1 + c1 * t_minus_1 * t_minus_1
261            }
262        }
263    }
264
265    /// Check if this easing can produce values outside 0.0-1.0.
266    pub fn can_overshoot(self) -> bool {
267        matches!(self, Self::Back)
268    }
269}
270
271// ============================================================================
272// Animation Configuration
273// ============================================================================
274
275/// Animation configuration for modals.
276#[derive(Debug, Clone)]
277pub struct ModalAnimationConfig {
278    /// Entrance animation type.
279    pub entrance: ModalEntranceAnimation,
280    /// Exit animation type.
281    pub exit: ModalExitAnimation,
282    /// Duration of entrance animation.
283    pub entrance_duration: Duration,
284    /// Duration of exit animation.
285    pub exit_duration: Duration,
286    /// Easing function for entrance.
287    pub entrance_easing: ModalEasing,
288    /// Easing function for exit.
289    pub exit_easing: ModalEasing,
290    /// Minimum scale for scale animations (typically 0.9-0.95).
291    pub min_scale: f64,
292    /// Whether backdrop should animate independently.
293    pub animate_backdrop: bool,
294    /// Backdrop fade-in duration (can differ from content).
295    pub backdrop_duration: Duration,
296    /// Whether to respect reduced-motion preference.
297    pub respect_reduced_motion: bool,
298}
299
300impl Default for ModalAnimationConfig {
301    fn default() -> Self {
302        Self {
303            entrance: ModalEntranceAnimation::ScaleIn,
304            exit: ModalExitAnimation::ScaleOut,
305            entrance_duration: Duration::from_millis(200),
306            exit_duration: Duration::from_millis(150),
307            entrance_easing: ModalEasing::EaseOut,
308            exit_easing: ModalEasing::EaseIn,
309            min_scale: 0.92,
310            animate_backdrop: true,
311            backdrop_duration: Duration::from_millis(150),
312            respect_reduced_motion: true,
313        }
314    }
315}
316
317impl ModalAnimationConfig {
318    /// Create a new default configuration.
319    pub fn new() -> Self {
320        Self::default()
321    }
322
323    /// Create a configuration with no animations.
324    pub fn none() -> Self {
325        Self {
326            entrance: ModalEntranceAnimation::None,
327            exit: ModalExitAnimation::None,
328            entrance_duration: Duration::ZERO,
329            exit_duration: Duration::ZERO,
330            backdrop_duration: Duration::ZERO,
331            ..Default::default()
332        }
333    }
334
335    /// Create a configuration for reduced motion preference.
336    ///
337    /// Uses fade only (no scale/slide) with shorter durations.
338    pub fn reduced_motion() -> Self {
339        Self {
340            entrance: ModalEntranceAnimation::FadeIn,
341            exit: ModalExitAnimation::FadeOut,
342            entrance_duration: Duration::from_millis(100),
343            exit_duration: Duration::from_millis(100),
344            entrance_easing: ModalEasing::Linear,
345            exit_easing: ModalEasing::Linear,
346            min_scale: 1.0,
347            animate_backdrop: true,
348            backdrop_duration: Duration::from_millis(100),
349            respect_reduced_motion: true,
350        }
351    }
352
353    /// Set entrance animation type.
354    pub fn entrance(mut self, anim: ModalEntranceAnimation) -> Self {
355        self.entrance = anim;
356        self
357    }
358
359    /// Set exit animation type.
360    pub fn exit(mut self, anim: ModalExitAnimation) -> Self {
361        self.exit = anim;
362        self
363    }
364
365    /// Set entrance duration.
366    pub fn entrance_duration(mut self, duration: Duration) -> Self {
367        self.entrance_duration = duration;
368        self
369    }
370
371    /// Set exit duration.
372    pub fn exit_duration(mut self, duration: Duration) -> Self {
373        self.exit_duration = duration;
374        self
375    }
376
377    /// Set entrance easing function.
378    pub fn entrance_easing(mut self, easing: ModalEasing) -> Self {
379        self.entrance_easing = easing;
380        self
381    }
382
383    /// Set exit easing function.
384    pub fn exit_easing(mut self, easing: ModalEasing) -> Self {
385        self.exit_easing = easing;
386        self
387    }
388
389    /// Set minimum scale for scale animations.
390    pub fn min_scale(mut self, scale: f64) -> Self {
391        self.min_scale = scale.clamp(0.5, 1.0);
392        self
393    }
394
395    /// Set whether backdrop should animate.
396    pub fn animate_backdrop(mut self, animate: bool) -> Self {
397        self.animate_backdrop = animate;
398        self
399    }
400
401    /// Set backdrop fade duration.
402    pub fn backdrop_duration(mut self, duration: Duration) -> Self {
403        self.backdrop_duration = duration;
404        self
405    }
406
407    /// Set whether to respect reduced-motion preference.
408    pub fn respect_reduced_motion(mut self, respect: bool) -> Self {
409        self.respect_reduced_motion = respect;
410        self
411    }
412
413    /// Check if animations are effectively disabled.
414    pub fn is_disabled(&self) -> bool {
415        matches!(self.entrance, ModalEntranceAnimation::None)
416            && matches!(self.exit, ModalExitAnimation::None)
417    }
418
419    /// Get the effective config, applying reduced motion if needed.
420    pub fn effective(&self, reduced_motion: bool) -> Self {
421        if reduced_motion && self.respect_reduced_motion {
422            Self::reduced_motion()
423        } else {
424            self.clone()
425        }
426    }
427}
428
429// ============================================================================
430// Animation State
431// ============================================================================
432
433/// Current animation state for a modal.
434///
435/// Tracks progress through open/close animations and computes
436/// interpolated values for scale, opacity, and position offset.
437#[derive(Debug, Clone)]
438pub struct ModalAnimationState {
439    /// Current animation phase.
440    phase: ModalAnimationPhase,
441    /// Progress within current phase (0.0 to 1.0).
442    progress: f64,
443    /// Backdrop animation progress (may differ from content).
444    backdrop_progress: f64,
445    /// Whether reduced motion is enabled.
446    reduced_motion: bool,
447}
448
449impl Default for ModalAnimationState {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455impl ModalAnimationState {
456    /// Create a new animation state (closed, no animation).
457    pub fn new() -> Self {
458        Self {
459            phase: ModalAnimationPhase::Closed,
460            progress: 0.0,
461            backdrop_progress: 0.0,
462            reduced_motion: false,
463        }
464    }
465
466    /// Create a state that starts fully open (for testing or instant open).
467    pub fn open() -> Self {
468        Self {
469            phase: ModalAnimationPhase::Open,
470            progress: 1.0,
471            backdrop_progress: 1.0,
472            reduced_motion: false,
473        }
474    }
475
476    /// Get the current phase.
477    pub fn phase(&self) -> ModalAnimationPhase {
478        self.phase
479    }
480
481    /// Get the raw progress value (0.0 to 1.0).
482    pub fn progress(&self) -> f64 {
483        self.progress
484    }
485
486    /// Get the backdrop progress value (0.0 to 1.0).
487    pub fn backdrop_progress(&self) -> f64 {
488        self.backdrop_progress
489    }
490
491    /// Check if the modal is visible (should be rendered).
492    pub fn is_visible(&self) -> bool {
493        self.phase.is_visible()
494    }
495
496    /// Check if animation is in progress.
497    pub fn is_animating(&self) -> bool {
498        self.phase.is_animating()
499    }
500
501    /// Check if the modal is fully open.
502    pub fn is_open(&self) -> bool {
503        matches!(self.phase, ModalAnimationPhase::Open)
504    }
505
506    /// Check if the modal is fully closed.
507    pub fn is_closed(&self) -> bool {
508        matches!(self.phase, ModalAnimationPhase::Closed)
509    }
510
511    /// Set reduced motion preference.
512    pub fn set_reduced_motion(&mut self, enabled: bool) {
513        self.reduced_motion = enabled;
514    }
515
516    /// Start opening animation.
517    ///
518    /// If already opening or open, this is a no-op.
519    /// If closing, reverses direction and preserves momentum.
520    pub fn start_opening(&mut self) {
521        match self.phase {
522            ModalAnimationPhase::Closed => {
523                self.phase = ModalAnimationPhase::Opening;
524                self.progress = 0.0;
525                self.backdrop_progress = 0.0;
526            }
527            ModalAnimationPhase::Closing => {
528                // Reverse animation, preserving progress
529                self.phase = ModalAnimationPhase::Opening;
530                // Invert progress: if we were 30% through closing, start at 70% open
531                self.progress = 1.0 - self.progress;
532                self.backdrop_progress = 1.0 - self.backdrop_progress;
533            }
534            ModalAnimationPhase::Opening | ModalAnimationPhase::Open => {
535                // Already opening or open, nothing to do
536            }
537        }
538    }
539
540    /// Start closing animation.
541    ///
542    /// If already closing or closed, this is a no-op.
543    /// If opening, reverses direction and preserves momentum.
544    pub fn start_closing(&mut self) {
545        match self.phase {
546            ModalAnimationPhase::Open => {
547                self.phase = ModalAnimationPhase::Closing;
548                self.progress = 0.0;
549                self.backdrop_progress = 0.0;
550            }
551            ModalAnimationPhase::Opening => {
552                // Reverse animation, preserving progress
553                self.phase = ModalAnimationPhase::Closing;
554                // Invert progress
555                self.progress = 1.0 - self.progress;
556                self.backdrop_progress = 1.0 - self.backdrop_progress;
557            }
558            ModalAnimationPhase::Closing | ModalAnimationPhase::Closed => {
559                // Already closing or closed, nothing to do
560            }
561        }
562    }
563
564    /// Force the modal to be fully open (skip animation).
565    pub fn force_open(&mut self) {
566        self.phase = ModalAnimationPhase::Open;
567        self.progress = 1.0;
568        self.backdrop_progress = 1.0;
569    }
570
571    /// Force the modal to be fully closed (skip animation).
572    pub fn force_close(&mut self) {
573        self.phase = ModalAnimationPhase::Closed;
574        self.progress = 0.0;
575        self.backdrop_progress = 0.0;
576    }
577
578    /// Advance the animation by the given delta time.
579    ///
580    /// Returns `true` if the animation phase changed (e.g., Opening → Open).
581    pub fn tick(&mut self, delta: Duration, config: &ModalAnimationConfig) -> bool {
582        let delta_secs = delta.as_secs_f64().max(0.0);
583        let config = config.effective(self.reduced_motion);
584
585        match self.phase {
586            ModalAnimationPhase::Opening => {
587                let content_duration = config.entrance_duration.as_secs_f64();
588                let backdrop_duration = if config.animate_backdrop {
589                    config.backdrop_duration.as_secs_f64()
590                } else {
591                    0.0
592                };
593
594                // Advance content progress
595                if content_duration > 0.0 {
596                    self.progress += delta_secs / content_duration;
597                } else {
598                    self.progress = 1.0;
599                }
600
601                // Advance backdrop progress
602                if backdrop_duration > 0.0 {
603                    self.backdrop_progress += delta_secs / backdrop_duration;
604                } else {
605                    self.backdrop_progress = 1.0;
606                }
607
608                // Clamp and check for completion
609                self.progress = self.progress.min(1.0);
610                self.backdrop_progress = self.backdrop_progress.min(1.0);
611
612                if self.progress >= 1.0 && self.backdrop_progress >= 1.0 {
613                    self.phase = ModalAnimationPhase::Open;
614                    self.progress = 1.0;
615                    self.backdrop_progress = 1.0;
616                    return true;
617                }
618            }
619            ModalAnimationPhase::Closing => {
620                let content_duration = config.exit_duration.as_secs_f64();
621                let backdrop_duration = if config.animate_backdrop {
622                    config.backdrop_duration.as_secs_f64()
623                } else {
624                    0.0
625                };
626
627                // Advance content progress
628                if content_duration > 0.0 {
629                    self.progress += delta_secs / content_duration;
630                } else {
631                    self.progress = 1.0;
632                }
633
634                // Advance backdrop progress
635                if backdrop_duration > 0.0 {
636                    self.backdrop_progress += delta_secs / backdrop_duration;
637                } else {
638                    self.backdrop_progress = 1.0;
639                }
640
641                // Clamp and check for completion
642                self.progress = self.progress.min(1.0);
643                self.backdrop_progress = self.backdrop_progress.min(1.0);
644
645                if self.progress >= 1.0 && self.backdrop_progress >= 1.0 {
646                    self.phase = ModalAnimationPhase::Closed;
647                    self.progress = 0.0;
648                    self.backdrop_progress = 0.0;
649                    return true;
650                }
651            }
652            ModalAnimationPhase::Open | ModalAnimationPhase::Closed => {
653                // No animation in progress
654            }
655        }
656
657        false
658    }
659
660    /// Get the current eased progress for content animation.
661    pub fn eased_progress(&self, config: &ModalAnimationConfig) -> f64 {
662        let config = config.effective(self.reduced_motion);
663        match self.phase {
664            ModalAnimationPhase::Opening => config.entrance_easing.apply(self.progress),
665            ModalAnimationPhase::Closing => config.exit_easing.apply(self.progress),
666            ModalAnimationPhase::Open => 1.0,
667            ModalAnimationPhase::Closed => 0.0,
668        }
669    }
670
671    /// Get the current eased progress for backdrop animation.
672    pub fn eased_backdrop_progress(&self, config: &ModalAnimationConfig) -> f64 {
673        let _config = config.effective(self.reduced_motion);
674        // Backdrop always uses EaseOut for fade-in and EaseIn for fade-out
675        match self.phase {
676            ModalAnimationPhase::Opening => ModalEasing::EaseOut.apply(self.backdrop_progress),
677            ModalAnimationPhase::Closing => ModalEasing::EaseIn.apply(self.backdrop_progress),
678            ModalAnimationPhase::Open => 1.0,
679            ModalAnimationPhase::Closed => 0.0,
680        }
681    }
682
683    /// Get the current scale factor for the modal content.
684    ///
685    /// Returns a value in [min_scale, 1.0].
686    pub fn current_scale(&self, config: &ModalAnimationConfig) -> f64 {
687        let config = config.effective(self.reduced_motion);
688        let eased = self.eased_progress(&config);
689
690        match self.phase {
691            ModalAnimationPhase::Opening => config.entrance.scale_at_progress(eased, &config),
692            ModalAnimationPhase::Closing => config.exit.scale_at_progress(eased, &config),
693            ModalAnimationPhase::Open => 1.0,
694            ModalAnimationPhase::Closed => config.entrance.initial_scale(&config),
695        }
696    }
697
698    /// Get the current opacity for the modal content.
699    ///
700    /// Returns a value in [0.0, 1.0].
701    pub fn current_opacity(&self, config: &ModalAnimationConfig) -> f64 {
702        let config = config.effective(self.reduced_motion);
703        let eased = self.eased_progress(&config);
704
705        match self.phase {
706            ModalAnimationPhase::Opening => config.entrance.opacity_at_progress(eased),
707            ModalAnimationPhase::Closing => config.exit.opacity_at_progress(eased),
708            ModalAnimationPhase::Open => 1.0,
709            ModalAnimationPhase::Closed => 0.0,
710        }
711    }
712
713    /// Get the current backdrop opacity.
714    ///
715    /// Returns a value in [0.0, 1.0] to be multiplied with the backdrop's configured opacity.
716    pub fn current_backdrop_opacity(&self, config: &ModalAnimationConfig) -> f64 {
717        let config = config.effective(self.reduced_motion);
718
719        if !config.animate_backdrop {
720            return match self.phase {
721                ModalAnimationPhase::Open | ModalAnimationPhase::Opening => 1.0,
722                ModalAnimationPhase::Closed | ModalAnimationPhase::Closing => 0.0,
723            };
724        }
725
726        let eased = self.eased_backdrop_progress(&config);
727
728        match self.phase {
729            ModalAnimationPhase::Opening => eased,
730            ModalAnimationPhase::Closing => 1.0 - eased,
731            ModalAnimationPhase::Open => 1.0,
732            ModalAnimationPhase::Closed => 0.0,
733        }
734    }
735
736    /// Get the current Y offset for the modal content.
737    ///
738    /// Returns an offset in cells (negative = above final position).
739    pub fn current_y_offset(&self, config: &ModalAnimationConfig, modal_height: u16) -> i16 {
740        let config = config.effective(self.reduced_motion);
741        let eased = self.eased_progress(&config);
742
743        match self.phase {
744            ModalAnimationPhase::Opening => {
745                config.entrance.y_offset_at_progress(eased, modal_height)
746            }
747            ModalAnimationPhase::Closing => config.exit.y_offset_at_progress(eased, modal_height),
748            ModalAnimationPhase::Open | ModalAnimationPhase::Closed => 0,
749        }
750    }
751
752    /// Get all current animation values at once.
753    ///
754    /// Returns (scale, opacity, backdrop_opacity, y_offset).
755    pub fn current_values(
756        &self,
757        config: &ModalAnimationConfig,
758        modal_height: u16,
759    ) -> (f64, f64, f64, i16) {
760        (
761            self.current_scale(config),
762            self.current_opacity(config),
763            self.current_backdrop_opacity(config),
764            self.current_y_offset(config, modal_height),
765        )
766    }
767}
768
769// ============================================================================
770// Tests
771// ============================================================================
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    // -------------------------------------------------------------------------
778    // Phase Transitions
779    // -------------------------------------------------------------------------
780
781    #[test]
782    fn test_phase_visibility() {
783        assert!(!ModalAnimationPhase::Closed.is_visible());
784        assert!(ModalAnimationPhase::Opening.is_visible());
785        assert!(ModalAnimationPhase::Open.is_visible());
786        assert!(ModalAnimationPhase::Closing.is_visible());
787    }
788
789    #[test]
790    fn test_phase_animating() {
791        assert!(!ModalAnimationPhase::Closed.is_animating());
792        assert!(ModalAnimationPhase::Opening.is_animating());
793        assert!(!ModalAnimationPhase::Open.is_animating());
794        assert!(ModalAnimationPhase::Closing.is_animating());
795    }
796
797    #[test]
798    fn test_start_opening_from_closed() {
799        let mut state = ModalAnimationState::new();
800        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
801
802        state.start_opening();
803        assert_eq!(state.phase(), ModalAnimationPhase::Opening);
804        assert_eq!(state.progress(), 0.0);
805    }
806
807    #[test]
808    fn test_start_closing_from_open() {
809        let mut state = ModalAnimationState::open();
810        assert_eq!(state.phase(), ModalAnimationPhase::Open);
811
812        state.start_closing();
813        assert_eq!(state.phase(), ModalAnimationPhase::Closing);
814        assert_eq!(state.progress(), 0.0);
815    }
816
817    #[test]
818    fn test_rapid_toggle_reverses_animation() {
819        let mut state = ModalAnimationState::new();
820        let config = ModalAnimationConfig::default();
821
822        // Start opening
823        state.start_opening();
824        state.tick(Duration::from_millis(100), &config); // 50% through 200ms
825
826        let opening_progress = state.progress();
827        assert!(opening_progress > 0.0);
828        assert!(opening_progress < 1.0);
829
830        // Quickly close - should reverse
831        state.start_closing();
832        assert_eq!(state.phase(), ModalAnimationPhase::Closing);
833
834        // Progress should be inverted: if we were 50% open, we're now 50% closed
835        let closing_progress = state.progress();
836        assert!((opening_progress + closing_progress - 1.0).abs() < 0.001);
837    }
838
839    #[test]
840    fn test_opening_noop_when_already_opening() {
841        let mut state = ModalAnimationState::new();
842        state.start_opening();
843        let progress1 = state.progress();
844
845        state.start_opening(); // Should be no-op
846        assert_eq!(state.progress(), progress1);
847        assert_eq!(state.phase(), ModalAnimationPhase::Opening);
848    }
849
850    // -------------------------------------------------------------------------
851    // Animation Progress
852    // -------------------------------------------------------------------------
853
854    #[test]
855    fn test_tick_advances_progress() {
856        let mut state = ModalAnimationState::new();
857        let config = ModalAnimationConfig::default();
858
859        state.start_opening();
860        assert_eq!(state.progress(), 0.0);
861
862        state.tick(Duration::from_millis(100), &config);
863        assert!(state.progress() > 0.0);
864        assert!(state.progress() < 1.0);
865    }
866
867    #[test]
868    fn test_tick_completes_animation() {
869        let mut state = ModalAnimationState::new();
870        let config = ModalAnimationConfig::default();
871
872        state.start_opening();
873        let changed = state.tick(Duration::from_millis(500), &config);
874
875        assert!(changed);
876        assert_eq!(state.phase(), ModalAnimationPhase::Open);
877        assert_eq!(state.progress(), 1.0);
878    }
879
880    #[test]
881    fn test_zero_duration_completes_instantly() {
882        let mut state = ModalAnimationState::new();
883        let config = ModalAnimationConfig::none();
884
885        state.start_opening();
886        let changed = state.tick(Duration::from_millis(1), &config);
887
888        assert!(changed);
889        assert_eq!(state.phase(), ModalAnimationPhase::Open);
890    }
891
892    // -------------------------------------------------------------------------
893    // Easing
894    // -------------------------------------------------------------------------
895
896    #[test]
897    fn test_easing_linear() {
898        assert_eq!(ModalEasing::Linear.apply(0.0), 0.0);
899        assert_eq!(ModalEasing::Linear.apply(0.5), 0.5);
900        assert_eq!(ModalEasing::Linear.apply(1.0), 1.0);
901    }
902
903    #[test]
904    fn test_easing_clamps_input() {
905        assert_eq!(ModalEasing::Linear.apply(-0.5), 0.0);
906        assert_eq!(ModalEasing::Linear.apply(1.5), 1.0);
907    }
908
909    #[test]
910    fn test_easing_ease_out_decelerates() {
911        // EaseOut should be > linear at 0.5 (faster start, slower end)
912        let linear = ModalEasing::Linear.apply(0.5);
913        let ease_out = ModalEasing::EaseOut.apply(0.5);
914        assert!(ease_out > linear);
915    }
916
917    #[test]
918    fn test_easing_ease_in_accelerates() {
919        // EaseIn should be < linear at 0.5 (slower start, faster end)
920        let linear = ModalEasing::Linear.apply(0.5);
921        let ease_in = ModalEasing::EaseIn.apply(0.5);
922        assert!(ease_in < linear);
923    }
924
925    // -------------------------------------------------------------------------
926    // Animation Values
927    // -------------------------------------------------------------------------
928
929    #[test]
930    fn test_scale_during_opening() {
931        let mut state = ModalAnimationState::new();
932        let config = ModalAnimationConfig::default();
933
934        // At start (closed)
935        let scale = state.current_scale(&config);
936        assert!((scale - config.min_scale).abs() < 0.001);
937
938        // During opening
939        state.start_opening();
940        state.tick(Duration::from_millis(100), &config);
941        let mid_scale = state.current_scale(&config);
942        assert!(mid_scale > config.min_scale);
943        assert!(mid_scale < 1.0);
944
945        // At end (open)
946        state.tick(Duration::from_millis(500), &config);
947        let final_scale = state.current_scale(&config);
948        assert!((final_scale - 1.0).abs() < 0.001);
949    }
950
951    #[test]
952    fn test_opacity_during_closing() {
953        let mut state = ModalAnimationState::open();
954        let config = ModalAnimationConfig::default();
955
956        // At start (open)
957        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
958
959        // During closing
960        state.start_closing();
961        state.tick(Duration::from_millis(75), &config);
962        let mid_opacity = state.current_opacity(&config);
963        assert!(mid_opacity > 0.0);
964        assert!(mid_opacity < 1.0);
965
966        // At end (closed)
967        state.tick(Duration::from_millis(500), &config);
968        let final_opacity = state.current_opacity(&config);
969        assert!((final_opacity - 0.0).abs() < 0.001);
970    }
971
972    #[test]
973    fn test_backdrop_opacity_independent() {
974        let mut state = ModalAnimationState::new();
975        let config = ModalAnimationConfig::default()
976            .entrance_duration(Duration::from_millis(200))
977            .backdrop_duration(Duration::from_millis(100));
978
979        state.start_opening();
980
981        // After 100ms, backdrop should be at 100% but content still animating
982        state.tick(Duration::from_millis(100), &config);
983
984        let content_opacity = state.current_opacity(&config);
985        let backdrop_opacity = state.current_backdrop_opacity(&config);
986
987        // Backdrop animates faster, so should be closer to 1.0
988        assert!(backdrop_opacity > content_opacity);
989    }
990
991    // -------------------------------------------------------------------------
992    // Reduced Motion
993    // -------------------------------------------------------------------------
994
995    #[test]
996    fn test_reduced_motion_config() {
997        let config = ModalAnimationConfig::reduced_motion();
998
999        assert!(matches!(config.entrance, ModalEntranceAnimation::FadeIn));
1000        assert!(matches!(config.exit, ModalExitAnimation::FadeOut));
1001        assert!((config.min_scale - 1.0).abs() < 0.001); // No scale
1002    }
1003
1004    #[test]
1005    fn test_reduced_motion_applies_effective_config() {
1006        let mut state = ModalAnimationState::new();
1007        state.set_reduced_motion(true);
1008
1009        let config = ModalAnimationConfig::default();
1010
1011        state.start_opening();
1012        let scale = state.current_scale(&config);
1013
1014        // With reduced motion, scale should be 1.0 (no scale animation)
1015        assert!((scale - 1.0).abs() < 0.001);
1016    }
1017
1018    // -------------------------------------------------------------------------
1019    // Force Open/Close
1020    // -------------------------------------------------------------------------
1021
1022    #[test]
1023    fn test_force_open() {
1024        let mut state = ModalAnimationState::new();
1025        state.force_open();
1026
1027        assert_eq!(state.phase(), ModalAnimationPhase::Open);
1028        assert_eq!(state.progress(), 1.0);
1029        assert_eq!(state.backdrop_progress(), 1.0);
1030    }
1031
1032    #[test]
1033    fn test_force_close() {
1034        let mut state = ModalAnimationState::open();
1035        state.force_close();
1036
1037        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
1038        assert_eq!(state.progress(), 0.0);
1039        assert_eq!(state.backdrop_progress(), 0.0);
1040    }
1041
1042    // -------------------------------------------------------------------------
1043    // Entrance/Exit Animation Types
1044    // -------------------------------------------------------------------------
1045
1046    #[test]
1047    fn test_scale_in_initial_scale() {
1048        let config = ModalAnimationConfig::default();
1049        let initial = ModalEntranceAnimation::ScaleIn.initial_scale(&config);
1050        assert!((initial - config.min_scale).abs() < 0.001);
1051    }
1052
1053    #[test]
1054    fn test_fade_in_no_scale() {
1055        let config = ModalAnimationConfig::default();
1056        let initial = ModalEntranceAnimation::FadeIn.initial_scale(&config);
1057        assert!((initial - 1.0).abs() < 0.001);
1058    }
1059
1060    #[test]
1061    fn test_slide_down_y_offset() {
1062        let initial = ModalEntranceAnimation::SlideDown.initial_y_offset(20);
1063        assert!(initial < 0); // Above final position
1064    }
1065
1066    #[test]
1067    fn test_slide_up_y_offset() {
1068        let initial = ModalEntranceAnimation::SlideUp.initial_y_offset(20);
1069        assert!(initial > 0); // Below final position
1070    }
1071
1072    // -------------------------------------------------------------------------
1073    // Invariants
1074    // -------------------------------------------------------------------------
1075
1076    #[test]
1077    fn test_progress_always_in_bounds() {
1078        let mut state = ModalAnimationState::new();
1079        let config = ModalAnimationConfig::default();
1080
1081        state.start_opening();
1082
1083        // Many ticks with large delta
1084        for _ in 0..100 {
1085            state.tick(Duration::from_millis(100), &config);
1086            assert!(state.progress() >= 0.0);
1087            assert!(state.progress() <= 1.0);
1088            assert!(state.backdrop_progress() >= 0.0);
1089            assert!(state.backdrop_progress() <= 1.0);
1090        }
1091    }
1092
1093    #[test]
1094    fn test_scale_always_in_bounds() {
1095        let mut state = ModalAnimationState::new();
1096        let config = ModalAnimationConfig::default();
1097
1098        state.start_opening();
1099
1100        for i in 0..20 {
1101            state.tick(Duration::from_millis(20), &config);
1102            let scale = state.current_scale(&config);
1103            assert!(
1104                scale >= config.min_scale,
1105                "scale {} < min {} at step {}",
1106                scale,
1107                config.min_scale,
1108                i
1109            );
1110            assert!(scale <= 1.0, "scale {} > 1.0 at step {}", scale, i);
1111        }
1112    }
1113
1114    #[test]
1115    fn test_opacity_always_in_bounds() {
1116        let mut state = ModalAnimationState::new();
1117        let config = ModalAnimationConfig::default();
1118
1119        state.start_opening();
1120
1121        for i in 0..20 {
1122            state.tick(Duration::from_millis(20), &config);
1123            let opacity = state.current_opacity(&config);
1124            assert!(opacity >= 0.0, "opacity {} < 0 at step {}", opacity, i);
1125            assert!(opacity <= 1.0, "opacity {} > 1.0 at step {}", opacity, i);
1126        }
1127    }
1128}