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    #[inline]
64    pub fn is_visible(self) -> bool {
65        !matches!(self, Self::Closed)
66    }
67
68    /// Check if animation is in progress.
69    #[inline]
70    pub fn is_animating(self) -> bool {
71        matches!(self, Self::Opening | Self::Closing)
72    }
73}
74
75// ============================================================================
76// Entrance Animation Types
77// ============================================================================
78
79/// Entrance animation type for modal content.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum ModalEntranceAnimation {
82    /// Scale up from center (classic modal pop).
83    #[default]
84    ScaleIn,
85    /// Fade in (opacity only, no scale).
86    FadeIn,
87    /// Slide down from top with fade.
88    SlideDown,
89    /// Slide up from bottom with fade.
90    SlideUp,
91    /// No animation (instant appear).
92    None,
93}
94
95impl ModalEntranceAnimation {
96    /// Get the initial scale factor for this animation.
97    ///
98    /// Returns a scale in [0.0, 1.0] where 1.0 = full size.
99    pub fn initial_scale(self, config: &ModalAnimationConfig) -> f64 {
100        match self {
101            Self::ScaleIn => config.min_scale,
102            Self::FadeIn | Self::SlideDown | Self::SlideUp | Self::None => 1.0,
103        }
104    }
105
106    /// Get the initial opacity for this animation.
107    pub fn initial_opacity(self) -> f64 {
108        match self {
109            Self::ScaleIn | Self::FadeIn | Self::SlideDown | Self::SlideUp => 0.0,
110            Self::None => 1.0,
111        }
112    }
113
114    /// Get the initial Y offset in cells for this animation.
115    pub fn initial_y_offset(self, modal_height: u16) -> i16 {
116        match self {
117            Self::SlideDown => -(modal_height as i16).min(8),
118            Self::SlideUp => (modal_height as i16).min(8),
119            Self::ScaleIn | Self::FadeIn | Self::None => 0,
120        }
121    }
122
123    /// Calculate scale at a given eased progress (0.0 to 1.0).
124    pub fn scale_at_progress(self, progress: f64, config: &ModalAnimationConfig) -> f64 {
125        let initial = self.initial_scale(config);
126        let p = progress.clamp(0.0, 1.0);
127        initial + (1.0 - initial) * p
128    }
129
130    /// Calculate opacity at a given eased progress (0.0 to 1.0).
131    pub fn opacity_at_progress(self, progress: f64) -> f64 {
132        let initial = self.initial_opacity();
133        let p = progress.clamp(0.0, 1.0);
134        initial + (1.0 - initial) * p
135    }
136
137    /// Calculate Y offset at a given eased progress (0.0 to 1.0).
138    pub fn y_offset_at_progress(self, progress: f64, modal_height: u16) -> i16 {
139        let initial = self.initial_y_offset(modal_height);
140        let p = progress.clamp(0.0, 1.0);
141        let inv = 1.0 - p;
142        (initial as f64 * inv).round() as i16
143    }
144}
145
146// ============================================================================
147// Exit Animation Types
148// ============================================================================
149
150/// Exit animation type for modal content.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
152pub enum ModalExitAnimation {
153    /// Scale down to center (reverse of ScaleIn).
154    #[default]
155    ScaleOut,
156    /// Fade out (opacity only, no scale).
157    FadeOut,
158    /// Slide up with fade.
159    SlideUp,
160    /// Slide down with fade.
161    SlideDown,
162    /// No animation (instant disappear).
163    None,
164}
165
166impl ModalExitAnimation {
167    /// Get the final scale factor for this animation.
168    pub fn final_scale(self, config: &ModalAnimationConfig) -> f64 {
169        match self {
170            Self::ScaleOut => config.min_scale,
171            Self::FadeOut | Self::SlideUp | Self::SlideDown | Self::None => 1.0,
172        }
173    }
174
175    /// Get the final opacity for this animation.
176    pub fn final_opacity(self) -> f64 {
177        match self {
178            Self::ScaleOut | Self::FadeOut | Self::SlideUp | Self::SlideDown => 0.0,
179            Self::None => 0.0, // Still 0 because modal is closing
180        }
181    }
182
183    /// Get the final Y offset in cells for this animation.
184    pub fn final_y_offset(self, modal_height: u16) -> i16 {
185        match self {
186            Self::SlideUp => -(modal_height as i16).min(8),
187            Self::SlideDown => (modal_height as i16).min(8),
188            Self::ScaleOut | Self::FadeOut | Self::None => 0,
189        }
190    }
191
192    /// Calculate scale at a given eased progress (0.0 to 1.0).
193    ///
194    /// Progress 0.0 = full size, 1.0 = final (shrunken).
195    pub fn scale_at_progress(self, progress: f64, config: &ModalAnimationConfig) -> f64 {
196        let final_scale = self.final_scale(config);
197        let p = progress.clamp(0.0, 1.0);
198        1.0 - (1.0 - final_scale) * p
199    }
200
201    /// Calculate opacity at a given eased progress (0.0 to 1.0).
202    pub fn opacity_at_progress(self, progress: f64) -> f64 {
203        let p = progress.clamp(0.0, 1.0);
204        1.0 - p
205    }
206
207    /// Calculate Y offset at a given eased progress (0.0 to 1.0).
208    pub fn y_offset_at_progress(self, progress: f64, modal_height: u16) -> i16 {
209        let final_offset = self.final_y_offset(modal_height);
210        let p = progress.clamp(0.0, 1.0);
211        (final_offset as f64 * p).round() as i16
212    }
213}
214
215// ============================================================================
216// Easing Functions
217// ============================================================================
218
219/// Easing function for modal animations.
220///
221/// Simplified subset of easing curves for modal animations.
222/// For the full set, see `ftui_extras::text_effects::Easing`.
223#[derive(Debug, Clone, Copy, PartialEq, Default)]
224pub enum ModalEasing {
225    /// Linear interpolation.
226    Linear,
227    /// Smooth ease-out (decelerating) - good for entrances.
228    #[default]
229    EaseOut,
230    /// Smooth ease-in (accelerating) - good for exits.
231    EaseIn,
232    /// Smooth S-curve - good for general transitions.
233    EaseInOut,
234    /// Slight overshoot then settle - bouncy feel.
235    Back,
236}
237
238impl ModalEasing {
239    /// Apply the easing function to a progress value (0.0 to 1.0).
240    pub fn apply(self, t: f64) -> f64 {
241        let t = t.clamp(0.0, 1.0);
242        match self {
243            Self::Linear => t,
244            Self::EaseOut => {
245                let inv = 1.0 - t;
246                1.0 - inv * inv * inv
247            }
248            Self::EaseIn => t * t * t,
249            Self::EaseInOut => {
250                if t < 0.5 {
251                    4.0 * t * t * t
252                } else {
253                    let inv = -2.0 * t + 2.0;
254                    1.0 - inv * inv * inv / 2.0
255                }
256            }
257            Self::Back => {
258                // Back ease-out: slight overshoot then settle
259                let c1 = 1.70158;
260                let c3 = c1 + 1.0;
261                let t_minus_1 = t - 1.0;
262                1.0 + c3 * t_minus_1 * t_minus_1 * t_minus_1 + c1 * t_minus_1 * t_minus_1
263            }
264        }
265    }
266
267    /// Check if this easing can produce values outside 0.0-1.0.
268    pub fn can_overshoot(self) -> bool {
269        matches!(self, Self::Back)
270    }
271}
272
273// ============================================================================
274// Animation Configuration
275// ============================================================================
276
277/// Animation configuration for modals.
278#[derive(Debug, Clone)]
279pub struct ModalAnimationConfig {
280    /// Entrance animation type.
281    pub entrance: ModalEntranceAnimation,
282    /// Exit animation type.
283    pub exit: ModalExitAnimation,
284    /// Duration of entrance animation.
285    pub entrance_duration: Duration,
286    /// Duration of exit animation.
287    pub exit_duration: Duration,
288    /// Easing function for entrance.
289    pub entrance_easing: ModalEasing,
290    /// Easing function for exit.
291    pub exit_easing: ModalEasing,
292    /// Minimum scale for scale animations (typically 0.9-0.95).
293    pub min_scale: f64,
294    /// Whether backdrop should animate independently.
295    pub animate_backdrop: bool,
296    /// Backdrop fade-in duration (can differ from content).
297    pub backdrop_duration: Duration,
298    /// Whether to respect reduced-motion preference.
299    pub respect_reduced_motion: bool,
300}
301
302impl Default for ModalAnimationConfig {
303    fn default() -> Self {
304        Self {
305            entrance: ModalEntranceAnimation::ScaleIn,
306            exit: ModalExitAnimation::ScaleOut,
307            entrance_duration: Duration::from_millis(200),
308            exit_duration: Duration::from_millis(150),
309            entrance_easing: ModalEasing::EaseOut,
310            exit_easing: ModalEasing::EaseIn,
311            min_scale: 0.92,
312            animate_backdrop: true,
313            backdrop_duration: Duration::from_millis(150),
314            respect_reduced_motion: true,
315        }
316    }
317}
318
319impl ModalAnimationConfig {
320    /// Create a new default configuration.
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Create a configuration with no animations.
326    pub fn none() -> Self {
327        Self {
328            entrance: ModalEntranceAnimation::None,
329            exit: ModalExitAnimation::None,
330            entrance_duration: Duration::ZERO,
331            exit_duration: Duration::ZERO,
332            backdrop_duration: Duration::ZERO,
333            ..Default::default()
334        }
335    }
336
337    /// Create a configuration for reduced motion preference.
338    ///
339    /// Uses fade only (no scale/slide) with shorter durations.
340    pub fn reduced_motion() -> Self {
341        Self {
342            entrance: ModalEntranceAnimation::FadeIn,
343            exit: ModalExitAnimation::FadeOut,
344            entrance_duration: Duration::from_millis(100),
345            exit_duration: Duration::from_millis(100),
346            entrance_easing: ModalEasing::Linear,
347            exit_easing: ModalEasing::Linear,
348            min_scale: 1.0,
349            animate_backdrop: true,
350            backdrop_duration: Duration::from_millis(100),
351            respect_reduced_motion: true,
352        }
353    }
354
355    /// Set entrance animation type.
356    #[must_use]
357    pub fn entrance(mut self, anim: ModalEntranceAnimation) -> Self {
358        self.entrance = anim;
359        self
360    }
361
362    /// Set exit animation type.
363    #[must_use]
364    pub fn exit(mut self, anim: ModalExitAnimation) -> Self {
365        self.exit = anim;
366        self
367    }
368
369    /// Set entrance duration.
370    #[must_use]
371    pub fn entrance_duration(mut self, duration: Duration) -> Self {
372        self.entrance_duration = duration;
373        self
374    }
375
376    /// Set exit duration.
377    #[must_use]
378    pub fn exit_duration(mut self, duration: Duration) -> Self {
379        self.exit_duration = duration;
380        self
381    }
382
383    /// Set entrance easing function.
384    #[must_use]
385    pub fn entrance_easing(mut self, easing: ModalEasing) -> Self {
386        self.entrance_easing = easing;
387        self
388    }
389
390    /// Set exit easing function.
391    #[must_use]
392    pub fn exit_easing(mut self, easing: ModalEasing) -> Self {
393        self.exit_easing = easing;
394        self
395    }
396
397    /// Set minimum scale for scale animations.
398    #[must_use]
399    pub fn min_scale(mut self, scale: f64) -> Self {
400        self.min_scale = scale.clamp(0.5, 1.0);
401        self
402    }
403
404    /// Set whether backdrop should animate.
405    #[must_use]
406    pub fn animate_backdrop(mut self, animate: bool) -> Self {
407        self.animate_backdrop = animate;
408        self
409    }
410
411    /// Set backdrop fade duration.
412    #[must_use]
413    pub fn backdrop_duration(mut self, duration: Duration) -> Self {
414        self.backdrop_duration = duration;
415        self
416    }
417
418    /// Set whether to respect reduced-motion preference.
419    #[must_use]
420    pub fn respect_reduced_motion(mut self, respect: bool) -> Self {
421        self.respect_reduced_motion = respect;
422        self
423    }
424
425    /// Check if animations are effectively disabled.
426    pub fn is_disabled(&self) -> bool {
427        matches!(self.entrance, ModalEntranceAnimation::None)
428            && matches!(self.exit, ModalExitAnimation::None)
429    }
430
431    /// Get the effective config, applying reduced motion if needed.
432    pub fn effective(&self, reduced_motion: bool) -> Self {
433        if reduced_motion && self.respect_reduced_motion {
434            Self::reduced_motion()
435        } else {
436            self.clone()
437        }
438    }
439}
440
441// ============================================================================
442// Animation State
443// ============================================================================
444
445/// Current animation state for a modal.
446///
447/// Tracks progress through open/close animations and computes
448/// interpolated values for scale, opacity, and position offset.
449#[derive(Debug, Clone)]
450pub struct ModalAnimationState {
451    /// Current animation phase.
452    phase: ModalAnimationPhase,
453    /// Progress within current phase (0.0 to 1.0).
454    progress: f64,
455    /// Backdrop animation progress (may differ from content).
456    backdrop_progress: f64,
457    /// Whether reduced motion is enabled.
458    reduced_motion: bool,
459}
460
461impl Default for ModalAnimationState {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467impl ModalAnimationState {
468    /// Create a new animation state (closed, no animation).
469    pub fn new() -> Self {
470        Self {
471            phase: ModalAnimationPhase::Closed,
472            progress: 0.0,
473            backdrop_progress: 0.0,
474            reduced_motion: false,
475        }
476    }
477
478    /// Create a state that starts fully open (for testing or instant open).
479    pub fn open() -> Self {
480        Self {
481            phase: ModalAnimationPhase::Open,
482            progress: 1.0,
483            backdrop_progress: 1.0,
484            reduced_motion: false,
485        }
486    }
487
488    /// Get the current phase.
489    pub fn phase(&self) -> ModalAnimationPhase {
490        self.phase
491    }
492
493    /// Get the raw progress value (0.0 to 1.0).
494    pub fn progress(&self) -> f64 {
495        self.progress
496    }
497
498    /// Get the backdrop progress value (0.0 to 1.0).
499    pub fn backdrop_progress(&self) -> f64 {
500        self.backdrop_progress
501    }
502
503    /// Check if the modal is visible (should be rendered).
504    #[inline]
505    pub fn is_visible(&self) -> bool {
506        self.phase.is_visible()
507    }
508
509    /// Check if animation is in progress.
510    #[inline]
511    pub fn is_animating(&self) -> bool {
512        self.phase.is_animating()
513    }
514
515    /// Check if the modal is fully open.
516    #[inline]
517    pub fn is_open(&self) -> bool {
518        matches!(self.phase, ModalAnimationPhase::Open)
519    }
520
521    /// Check if the modal is fully closed.
522    #[inline]
523    pub fn is_closed(&self) -> bool {
524        matches!(self.phase, ModalAnimationPhase::Closed)
525    }
526
527    /// Set reduced motion preference.
528    pub fn set_reduced_motion(&mut self, enabled: bool) {
529        self.reduced_motion = enabled;
530    }
531
532    /// Start opening animation.
533    ///
534    /// If already opening or open, this is a no-op.
535    /// If closing, reverses direction and preserves momentum.
536    pub fn start_opening(&mut self) {
537        match self.phase {
538            ModalAnimationPhase::Closed => {
539                self.phase = ModalAnimationPhase::Opening;
540                self.progress = 0.0;
541                self.backdrop_progress = 0.0;
542            }
543            ModalAnimationPhase::Closing => {
544                // Reverse animation, preserving progress
545                self.phase = ModalAnimationPhase::Opening;
546                // Invert progress: if we were 30% through closing, start at 70% open
547                self.progress = 1.0 - self.progress;
548                self.backdrop_progress = 1.0 - self.backdrop_progress;
549            }
550            ModalAnimationPhase::Opening | ModalAnimationPhase::Open => {
551                // Already opening or open, nothing to do
552            }
553        }
554    }
555
556    /// Start closing animation.
557    ///
558    /// If already closing or closed, this is a no-op.
559    /// If opening, reverses direction and preserves momentum.
560    pub fn start_closing(&mut self) {
561        match self.phase {
562            ModalAnimationPhase::Open => {
563                self.phase = ModalAnimationPhase::Closing;
564                self.progress = 0.0;
565                self.backdrop_progress = 0.0;
566            }
567            ModalAnimationPhase::Opening => {
568                // Reverse animation, preserving progress
569                self.phase = ModalAnimationPhase::Closing;
570                // Invert progress
571                self.progress = 1.0 - self.progress;
572                self.backdrop_progress = 1.0 - self.backdrop_progress;
573            }
574            ModalAnimationPhase::Closing | ModalAnimationPhase::Closed => {
575                // Already closing or closed, nothing to do
576            }
577        }
578    }
579
580    /// Force the modal to be fully open (skip animation).
581    pub fn force_open(&mut self) {
582        self.phase = ModalAnimationPhase::Open;
583        self.progress = 1.0;
584        self.backdrop_progress = 1.0;
585    }
586
587    /// Force the modal to be fully closed (skip animation).
588    pub fn force_close(&mut self) {
589        self.phase = ModalAnimationPhase::Closed;
590        self.progress = 0.0;
591        self.backdrop_progress = 0.0;
592    }
593
594    /// Advance the animation by the given delta time.
595    ///
596    /// Returns `true` if the animation phase changed (e.g., Opening → Open).
597    pub fn tick(&mut self, delta: Duration, config: &ModalAnimationConfig) -> bool {
598        let delta_secs = delta.as_secs_f64().max(0.0);
599        let config = config.effective(self.reduced_motion);
600
601        match self.phase {
602            ModalAnimationPhase::Opening => {
603                let content_duration = config.entrance_duration.as_secs_f64();
604                let backdrop_duration = if config.animate_backdrop {
605                    config.backdrop_duration.as_secs_f64()
606                } else {
607                    0.0
608                };
609
610                // Advance content progress
611                if content_duration > 0.0 {
612                    self.progress += delta_secs / content_duration;
613                } else {
614                    self.progress = 1.0;
615                }
616
617                // Advance backdrop progress
618                if backdrop_duration > 0.0 {
619                    self.backdrop_progress += delta_secs / backdrop_duration;
620                } else {
621                    self.backdrop_progress = 1.0;
622                }
623
624                // Clamp and check for completion
625                self.progress = self.progress.min(1.0);
626                self.backdrop_progress = self.backdrop_progress.min(1.0);
627
628                if self.progress >= 1.0 && self.backdrop_progress >= 1.0 {
629                    self.phase = ModalAnimationPhase::Open;
630                    self.progress = 1.0;
631                    self.backdrop_progress = 1.0;
632                    return true;
633                }
634            }
635            ModalAnimationPhase::Closing => {
636                let content_duration = config.exit_duration.as_secs_f64();
637                let backdrop_duration = if config.animate_backdrop {
638                    config.backdrop_duration.as_secs_f64()
639                } else {
640                    0.0
641                };
642
643                // Advance content progress
644                if content_duration > 0.0 {
645                    self.progress += delta_secs / content_duration;
646                } else {
647                    self.progress = 1.0;
648                }
649
650                // Advance backdrop progress
651                if backdrop_duration > 0.0 {
652                    self.backdrop_progress += delta_secs / backdrop_duration;
653                } else {
654                    self.backdrop_progress = 1.0;
655                }
656
657                // Clamp and check for completion
658                self.progress = self.progress.min(1.0);
659                self.backdrop_progress = self.backdrop_progress.min(1.0);
660
661                if self.progress >= 1.0 && self.backdrop_progress >= 1.0 {
662                    self.phase = ModalAnimationPhase::Closed;
663                    self.progress = 0.0;
664                    self.backdrop_progress = 0.0;
665                    return true;
666                }
667            }
668            ModalAnimationPhase::Open | ModalAnimationPhase::Closed => {
669                // No animation in progress
670            }
671        }
672
673        false
674    }
675
676    /// Get the current eased progress for content animation.
677    pub fn eased_progress(&self, config: &ModalAnimationConfig) -> f64 {
678        let config = config.effective(self.reduced_motion);
679        match self.phase {
680            ModalAnimationPhase::Opening => config.entrance_easing.apply(self.progress),
681            ModalAnimationPhase::Closing => config.exit_easing.apply(self.progress),
682            ModalAnimationPhase::Open => 1.0,
683            ModalAnimationPhase::Closed => 0.0,
684        }
685    }
686
687    /// Get the current eased progress for backdrop animation.
688    pub fn eased_backdrop_progress(&self, config: &ModalAnimationConfig) -> f64 {
689        let _config = config.effective(self.reduced_motion);
690        // Backdrop always uses EaseOut for fade-in and EaseIn for fade-out
691        match self.phase {
692            ModalAnimationPhase::Opening => ModalEasing::EaseOut.apply(self.backdrop_progress),
693            ModalAnimationPhase::Closing => ModalEasing::EaseIn.apply(self.backdrop_progress),
694            ModalAnimationPhase::Open => 1.0,
695            ModalAnimationPhase::Closed => 0.0,
696        }
697    }
698
699    /// Get the current scale factor for the modal content.
700    ///
701    /// Returns a value in [min_scale, 1.0].
702    pub fn current_scale(&self, config: &ModalAnimationConfig) -> f64 {
703        let config = config.effective(self.reduced_motion);
704        let eased = self.eased_progress(&config);
705
706        match self.phase {
707            ModalAnimationPhase::Opening => config.entrance.scale_at_progress(eased, &config),
708            ModalAnimationPhase::Closing => config.exit.scale_at_progress(eased, &config),
709            ModalAnimationPhase::Open => 1.0,
710            ModalAnimationPhase::Closed => config.entrance.initial_scale(&config),
711        }
712    }
713
714    /// Get the current opacity for the modal content.
715    ///
716    /// Returns a value in [0.0, 1.0].
717    pub fn current_opacity(&self, config: &ModalAnimationConfig) -> f64 {
718        let config = config.effective(self.reduced_motion);
719        let eased = self.eased_progress(&config);
720
721        match self.phase {
722            ModalAnimationPhase::Opening => config.entrance.opacity_at_progress(eased),
723            ModalAnimationPhase::Closing => config.exit.opacity_at_progress(eased),
724            ModalAnimationPhase::Open => 1.0,
725            ModalAnimationPhase::Closed => 0.0,
726        }
727    }
728
729    /// Get the current backdrop opacity.
730    ///
731    /// Returns a value in [0.0, 1.0] to be multiplied with the backdrop's configured opacity.
732    pub fn current_backdrop_opacity(&self, config: &ModalAnimationConfig) -> f64 {
733        let config = config.effective(self.reduced_motion);
734
735        if !config.animate_backdrop {
736            return match self.phase {
737                ModalAnimationPhase::Open | ModalAnimationPhase::Opening => 1.0,
738                ModalAnimationPhase::Closed | ModalAnimationPhase::Closing => 0.0,
739            };
740        }
741
742        let eased = self.eased_backdrop_progress(&config);
743
744        match self.phase {
745            ModalAnimationPhase::Opening => eased,
746            ModalAnimationPhase::Closing => 1.0 - eased,
747            ModalAnimationPhase::Open => 1.0,
748            ModalAnimationPhase::Closed => 0.0,
749        }
750    }
751
752    /// Get the current Y offset for the modal content.
753    ///
754    /// Returns an offset in cells (negative = above final position).
755    pub fn current_y_offset(&self, config: &ModalAnimationConfig, modal_height: u16) -> i16 {
756        let config = config.effective(self.reduced_motion);
757        let eased = self.eased_progress(&config);
758
759        match self.phase {
760            ModalAnimationPhase::Opening => {
761                config.entrance.y_offset_at_progress(eased, modal_height)
762            }
763            ModalAnimationPhase::Closing => config.exit.y_offset_at_progress(eased, modal_height),
764            ModalAnimationPhase::Open | ModalAnimationPhase::Closed => 0,
765        }
766    }
767
768    /// Get all current animation values at once.
769    ///
770    /// Returns (scale, opacity, backdrop_opacity, y_offset).
771    pub fn current_values(
772        &self,
773        config: &ModalAnimationConfig,
774        modal_height: u16,
775    ) -> (f64, f64, f64, i16) {
776        (
777            self.current_scale(config),
778            self.current_opacity(config),
779            self.current_backdrop_opacity(config),
780            self.current_y_offset(config, modal_height),
781        )
782    }
783}
784
785// ============================================================================
786// Tests
787// ============================================================================
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    // -------------------------------------------------------------------------
794    // Phase Transitions
795    // -------------------------------------------------------------------------
796
797    #[test]
798    fn test_phase_visibility() {
799        assert!(!ModalAnimationPhase::Closed.is_visible());
800        assert!(ModalAnimationPhase::Opening.is_visible());
801        assert!(ModalAnimationPhase::Open.is_visible());
802        assert!(ModalAnimationPhase::Closing.is_visible());
803    }
804
805    #[test]
806    fn test_phase_animating() {
807        assert!(!ModalAnimationPhase::Closed.is_animating());
808        assert!(ModalAnimationPhase::Opening.is_animating());
809        assert!(!ModalAnimationPhase::Open.is_animating());
810        assert!(ModalAnimationPhase::Closing.is_animating());
811    }
812
813    #[test]
814    fn test_start_opening_from_closed() {
815        let mut state = ModalAnimationState::new();
816        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
817
818        state.start_opening();
819        assert_eq!(state.phase(), ModalAnimationPhase::Opening);
820        assert_eq!(state.progress(), 0.0);
821    }
822
823    #[test]
824    fn test_start_closing_from_open() {
825        let mut state = ModalAnimationState::open();
826        assert_eq!(state.phase(), ModalAnimationPhase::Open);
827
828        state.start_closing();
829        assert_eq!(state.phase(), ModalAnimationPhase::Closing);
830        assert_eq!(state.progress(), 0.0);
831    }
832
833    #[test]
834    fn test_rapid_toggle_reverses_animation() {
835        let mut state = ModalAnimationState::new();
836        let config = ModalAnimationConfig::default();
837
838        // Start opening
839        state.start_opening();
840        state.tick(Duration::from_millis(100), &config); // 50% through 200ms
841
842        let opening_progress = state.progress();
843        assert!(opening_progress > 0.0);
844        assert!(opening_progress < 1.0);
845
846        // Quickly close - should reverse
847        state.start_closing();
848        assert_eq!(state.phase(), ModalAnimationPhase::Closing);
849
850        // Progress should be inverted: if we were 50% open, we're now 50% closed
851        let closing_progress = state.progress();
852        assert!((opening_progress + closing_progress - 1.0).abs() < 0.001);
853    }
854
855    #[test]
856    fn test_opening_noop_when_already_opening() {
857        let mut state = ModalAnimationState::new();
858        state.start_opening();
859        let progress1 = state.progress();
860
861        state.start_opening(); // Should be no-op
862        assert_eq!(state.progress(), progress1);
863        assert_eq!(state.phase(), ModalAnimationPhase::Opening);
864    }
865
866    // -------------------------------------------------------------------------
867    // Animation Progress
868    // -------------------------------------------------------------------------
869
870    #[test]
871    fn test_tick_advances_progress() {
872        let mut state = ModalAnimationState::new();
873        let config = ModalAnimationConfig::default();
874
875        state.start_opening();
876        assert_eq!(state.progress(), 0.0);
877
878        state.tick(Duration::from_millis(100), &config);
879        assert!(state.progress() > 0.0);
880        assert!(state.progress() < 1.0);
881    }
882
883    #[test]
884    fn test_tick_completes_animation() {
885        let mut state = ModalAnimationState::new();
886        let config = ModalAnimationConfig::default();
887
888        state.start_opening();
889        let changed = state.tick(Duration::from_millis(500), &config);
890
891        assert!(changed);
892        assert_eq!(state.phase(), ModalAnimationPhase::Open);
893        assert_eq!(state.progress(), 1.0);
894    }
895
896    #[test]
897    fn test_zero_duration_completes_instantly() {
898        let mut state = ModalAnimationState::new();
899        let config = ModalAnimationConfig::none();
900
901        state.start_opening();
902        let changed = state.tick(Duration::from_millis(1), &config);
903
904        assert!(changed);
905        assert_eq!(state.phase(), ModalAnimationPhase::Open);
906    }
907
908    // -------------------------------------------------------------------------
909    // Easing
910    // -------------------------------------------------------------------------
911
912    #[test]
913    fn test_easing_linear() {
914        assert_eq!(ModalEasing::Linear.apply(0.0), 0.0);
915        assert_eq!(ModalEasing::Linear.apply(0.5), 0.5);
916        assert_eq!(ModalEasing::Linear.apply(1.0), 1.0);
917    }
918
919    #[test]
920    fn test_easing_clamps_input() {
921        assert_eq!(ModalEasing::Linear.apply(-0.5), 0.0);
922        assert_eq!(ModalEasing::Linear.apply(1.5), 1.0);
923    }
924
925    #[test]
926    fn test_easing_ease_out_decelerates() {
927        // EaseOut should be > linear at 0.5 (faster start, slower end)
928        let linear = ModalEasing::Linear.apply(0.5);
929        let ease_out = ModalEasing::EaseOut.apply(0.5);
930        assert!(ease_out > linear);
931    }
932
933    #[test]
934    fn test_easing_ease_in_accelerates() {
935        // EaseIn should be < linear at 0.5 (slower start, faster end)
936        let linear = ModalEasing::Linear.apply(0.5);
937        let ease_in = ModalEasing::EaseIn.apply(0.5);
938        assert!(ease_in < linear);
939    }
940
941    // -------------------------------------------------------------------------
942    // Animation Values
943    // -------------------------------------------------------------------------
944
945    #[test]
946    fn test_scale_during_opening() {
947        let mut state = ModalAnimationState::new();
948        let config = ModalAnimationConfig::default();
949
950        // At start (closed)
951        let scale = state.current_scale(&config);
952        assert!((scale - config.min_scale).abs() < 0.001);
953
954        // During opening
955        state.start_opening();
956        state.tick(Duration::from_millis(100), &config);
957        let mid_scale = state.current_scale(&config);
958        assert!(mid_scale > config.min_scale);
959        assert!(mid_scale < 1.0);
960
961        // At end (open)
962        state.tick(Duration::from_millis(500), &config);
963        let final_scale = state.current_scale(&config);
964        assert!((final_scale - 1.0).abs() < 0.001);
965    }
966
967    #[test]
968    fn test_opacity_during_closing() {
969        let mut state = ModalAnimationState::open();
970        let config = ModalAnimationConfig::default();
971
972        // At start (open)
973        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
974
975        // During closing
976        state.start_closing();
977        state.tick(Duration::from_millis(75), &config);
978        let mid_opacity = state.current_opacity(&config);
979        assert!(mid_opacity > 0.0);
980        assert!(mid_opacity < 1.0);
981
982        // At end (closed)
983        state.tick(Duration::from_millis(500), &config);
984        let final_opacity = state.current_opacity(&config);
985        assert!((final_opacity - 0.0).abs() < 0.001);
986    }
987
988    #[test]
989    fn test_backdrop_opacity_independent() {
990        let mut state = ModalAnimationState::new();
991        let config = ModalAnimationConfig::default()
992            .entrance_duration(Duration::from_millis(200))
993            .backdrop_duration(Duration::from_millis(100));
994
995        state.start_opening();
996
997        // After 100ms, backdrop should be at 100% but content still animating
998        state.tick(Duration::from_millis(100), &config);
999
1000        let content_opacity = state.current_opacity(&config);
1001        let backdrop_opacity = state.current_backdrop_opacity(&config);
1002
1003        // Backdrop animates faster, so should be closer to 1.0
1004        assert!(backdrop_opacity > content_opacity);
1005    }
1006
1007    // -------------------------------------------------------------------------
1008    // Reduced Motion
1009    // -------------------------------------------------------------------------
1010
1011    #[test]
1012    fn test_reduced_motion_config() {
1013        let config = ModalAnimationConfig::reduced_motion();
1014
1015        assert!(matches!(config.entrance, ModalEntranceAnimation::FadeIn));
1016        assert!(matches!(config.exit, ModalExitAnimation::FadeOut));
1017        assert!((config.min_scale - 1.0).abs() < 0.001); // No scale
1018    }
1019
1020    #[test]
1021    fn test_reduced_motion_applies_effective_config() {
1022        let mut state = ModalAnimationState::new();
1023        state.set_reduced_motion(true);
1024
1025        let config = ModalAnimationConfig::default();
1026
1027        state.start_opening();
1028        let scale = state.current_scale(&config);
1029
1030        // With reduced motion, scale should be 1.0 (no scale animation)
1031        assert!((scale - 1.0).abs() < 0.001);
1032    }
1033
1034    // -------------------------------------------------------------------------
1035    // Force Open/Close
1036    // -------------------------------------------------------------------------
1037
1038    #[test]
1039    fn test_force_open() {
1040        let mut state = ModalAnimationState::new();
1041        state.force_open();
1042
1043        assert_eq!(state.phase(), ModalAnimationPhase::Open);
1044        assert_eq!(state.progress(), 1.0);
1045        assert_eq!(state.backdrop_progress(), 1.0);
1046    }
1047
1048    #[test]
1049    fn test_force_close() {
1050        let mut state = ModalAnimationState::open();
1051        state.force_close();
1052
1053        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
1054        assert_eq!(state.progress(), 0.0);
1055        assert_eq!(state.backdrop_progress(), 0.0);
1056    }
1057
1058    // -------------------------------------------------------------------------
1059    // Entrance/Exit Animation Types
1060    // -------------------------------------------------------------------------
1061
1062    #[test]
1063    fn test_scale_in_initial_scale() {
1064        let config = ModalAnimationConfig::default();
1065        let initial = ModalEntranceAnimation::ScaleIn.initial_scale(&config);
1066        assert!((initial - config.min_scale).abs() < 0.001);
1067    }
1068
1069    #[test]
1070    fn test_fade_in_no_scale() {
1071        let config = ModalAnimationConfig::default();
1072        let initial = ModalEntranceAnimation::FadeIn.initial_scale(&config);
1073        assert!((initial - 1.0).abs() < 0.001);
1074    }
1075
1076    #[test]
1077    fn test_slide_down_y_offset() {
1078        let initial = ModalEntranceAnimation::SlideDown.initial_y_offset(20);
1079        assert!(initial < 0); // Above final position
1080    }
1081
1082    #[test]
1083    fn test_slide_up_y_offset() {
1084        let initial = ModalEntranceAnimation::SlideUp.initial_y_offset(20);
1085        assert!(initial > 0); // Below final position
1086    }
1087
1088    // -------------------------------------------------------------------------
1089    // Invariants
1090    // -------------------------------------------------------------------------
1091
1092    #[test]
1093    fn test_progress_always_in_bounds() {
1094        let mut state = ModalAnimationState::new();
1095        let config = ModalAnimationConfig::default();
1096
1097        state.start_opening();
1098
1099        // Many ticks with large delta
1100        for _ in 0..100 {
1101            state.tick(Duration::from_millis(100), &config);
1102            assert!(state.progress() >= 0.0);
1103            assert!(state.progress() <= 1.0);
1104            assert!(state.backdrop_progress() >= 0.0);
1105            assert!(state.backdrop_progress() <= 1.0);
1106        }
1107    }
1108
1109    #[test]
1110    fn test_scale_always_in_bounds() {
1111        let mut state = ModalAnimationState::new();
1112        let config = ModalAnimationConfig::default();
1113
1114        state.start_opening();
1115
1116        for i in 0..20 {
1117            state.tick(Duration::from_millis(20), &config);
1118            let scale = state.current_scale(&config);
1119            assert!(
1120                scale >= config.min_scale,
1121                "scale {} < min {} at step {}",
1122                scale,
1123                config.min_scale,
1124                i
1125            );
1126            assert!(scale <= 1.0, "scale {} > 1.0 at step {}", scale, i);
1127        }
1128    }
1129
1130    #[test]
1131    fn test_opacity_always_in_bounds() {
1132        let mut state = ModalAnimationState::new();
1133        let config = ModalAnimationConfig::default();
1134
1135        state.start_opening();
1136
1137        for i in 0..20 {
1138            state.tick(Duration::from_millis(20), &config);
1139            let opacity = state.current_opacity(&config);
1140            assert!(opacity >= 0.0, "opacity {} < 0 at step {}", opacity, i);
1141            assert!(opacity <= 1.0, "opacity {} > 1.0 at step {}", opacity, i);
1142        }
1143    }
1144
1145    // ---- Edge-case tests (bd-a4n4z) ----
1146
1147    #[test]
1148    fn edge_easing_ease_in_out_at_boundary() {
1149        // At exactly 0.5 the branch flips
1150        let at_half = ModalEasing::EaseInOut.apply(0.5);
1151        assert!(
1152            (at_half - 0.5).abs() < 0.001,
1153            "EaseInOut at 0.5 should be ~0.5, got {at_half}"
1154        );
1155        // Endpoints
1156        assert_eq!(ModalEasing::EaseInOut.apply(0.0), 0.0);
1157        assert!((ModalEasing::EaseInOut.apply(1.0) - 1.0).abs() < 1e-10);
1158    }
1159
1160    #[test]
1161    fn edge_easing_back_overshoots() {
1162        // Back easing overshoots 1.0 briefly then settles at 1.0
1163        // At midpoint it should overshoot
1164        let mid = ModalEasing::Back.apply(0.5);
1165        // At t=0 and t=1
1166        assert!((ModalEasing::Back.apply(0.0)).abs() < 1e-10);
1167        assert!((ModalEasing::Back.apply(1.0) - 1.0).abs() < 1e-10);
1168        // Verify overshoot actually happens somewhere in (0, 1)
1169        let mut found_overshoot = false;
1170        for i in 1..100 {
1171            let t = i as f64 / 100.0;
1172            let v = ModalEasing::Back.apply(t);
1173            if v > 1.0 {
1174                found_overshoot = true;
1175                break;
1176            }
1177        }
1178        assert!(
1179            found_overshoot,
1180            "Back easing should overshoot 1.0 at some point, mid={mid}"
1181        );
1182    }
1183
1184    #[test]
1185    fn edge_can_overshoot_only_back() {
1186        assert!(!ModalEasing::Linear.can_overshoot());
1187        assert!(!ModalEasing::EaseOut.can_overshoot());
1188        assert!(!ModalEasing::EaseIn.can_overshoot());
1189        assert!(!ModalEasing::EaseInOut.can_overshoot());
1190        assert!(ModalEasing::Back.can_overshoot());
1191    }
1192
1193    #[test]
1194    fn edge_easing_ease_in_endpoints() {
1195        assert_eq!(ModalEasing::EaseIn.apply(0.0), 0.0);
1196        assert!((ModalEasing::EaseIn.apply(1.0) - 1.0).abs() < 1e-10);
1197    }
1198
1199    #[test]
1200    fn edge_easing_ease_out_endpoints() {
1201        assert_eq!(ModalEasing::EaseOut.apply(0.0), 0.0);
1202        assert!((ModalEasing::EaseOut.apply(1.0) - 1.0).abs() < 1e-10);
1203    }
1204
1205    #[test]
1206    fn edge_exit_final_scale_variants() {
1207        let config = ModalAnimationConfig::default();
1208        assert!(
1209            (ModalExitAnimation::ScaleOut.final_scale(&config) - config.min_scale).abs() < 1e-10
1210        );
1211        assert!((ModalExitAnimation::FadeOut.final_scale(&config) - 1.0).abs() < 1e-10);
1212        assert!((ModalExitAnimation::SlideUp.final_scale(&config) - 1.0).abs() < 1e-10);
1213        assert!((ModalExitAnimation::SlideDown.final_scale(&config) - 1.0).abs() < 1e-10);
1214        assert!((ModalExitAnimation::None.final_scale(&config) - 1.0).abs() < 1e-10);
1215    }
1216
1217    #[test]
1218    fn edge_exit_final_opacity_all_zero() {
1219        // All exit animations end at opacity 0
1220        assert_eq!(ModalExitAnimation::ScaleOut.final_opacity(), 0.0);
1221        assert_eq!(ModalExitAnimation::FadeOut.final_opacity(), 0.0);
1222        assert_eq!(ModalExitAnimation::SlideUp.final_opacity(), 0.0);
1223        assert_eq!(ModalExitAnimation::SlideDown.final_opacity(), 0.0);
1224        assert_eq!(ModalExitAnimation::None.final_opacity(), 0.0);
1225    }
1226
1227    #[test]
1228    fn edge_exit_final_y_offset() {
1229        assert!(ModalExitAnimation::SlideUp.final_y_offset(20) < 0);
1230        assert!(ModalExitAnimation::SlideDown.final_y_offset(20) > 0);
1231        assert_eq!(ModalExitAnimation::ScaleOut.final_y_offset(20), 0);
1232        assert_eq!(ModalExitAnimation::FadeOut.final_y_offset(20), 0);
1233        assert_eq!(ModalExitAnimation::None.final_y_offset(20), 0);
1234    }
1235
1236    #[test]
1237    fn edge_exit_scale_at_progress() {
1238        let config = ModalAnimationConfig::default();
1239        // At progress 0, scale = 1.0 (fully open)
1240        let s0 = ModalExitAnimation::ScaleOut.scale_at_progress(0.0, &config);
1241        assert!((s0 - 1.0).abs() < 1e-10);
1242        // At progress 1, scale = min_scale
1243        let s1 = ModalExitAnimation::ScaleOut.scale_at_progress(1.0, &config);
1244        assert!((s1 - config.min_scale).abs() < 1e-10);
1245    }
1246
1247    #[test]
1248    fn edge_exit_opacity_at_progress() {
1249        assert!((ModalExitAnimation::FadeOut.opacity_at_progress(0.0) - 1.0).abs() < 1e-10);
1250        assert!((ModalExitAnimation::FadeOut.opacity_at_progress(1.0)).abs() < 1e-10);
1251        assert!((ModalExitAnimation::FadeOut.opacity_at_progress(0.5) - 0.5).abs() < 1e-10);
1252    }
1253
1254    #[test]
1255    fn edge_exit_y_offset_at_progress() {
1256        assert_eq!(ModalExitAnimation::SlideUp.y_offset_at_progress(0.0, 20), 0);
1257        let final_offset = ModalExitAnimation::SlideUp.y_offset_at_progress(1.0, 20);
1258        assert_eq!(final_offset, ModalExitAnimation::SlideUp.final_y_offset(20));
1259    }
1260
1261    #[test]
1262    fn edge_entrance_none_instant() {
1263        let config = ModalAnimationConfig::default();
1264        assert!((ModalEntranceAnimation::None.initial_scale(&config) - 1.0).abs() < 1e-10);
1265        assert!((ModalEntranceAnimation::None.initial_opacity() - 1.0).abs() < 1e-10);
1266        assert_eq!(ModalEntranceAnimation::None.initial_y_offset(20), 0);
1267    }
1268
1269    #[test]
1270    fn edge_slide_height_clamped_at_8() {
1271        // Large modal_height should clamp offset at 8
1272        let down = ModalEntranceAnimation::SlideDown.initial_y_offset(100);
1273        assert_eq!(down, -8);
1274        let up = ModalEntranceAnimation::SlideUp.initial_y_offset(100);
1275        assert_eq!(up, 8);
1276
1277        // Exit slide clamping
1278        let exit_up = ModalExitAnimation::SlideUp.final_y_offset(100);
1279        assert_eq!(exit_up, -8);
1280        let exit_down = ModalExitAnimation::SlideDown.final_y_offset(100);
1281        assert_eq!(exit_down, 8);
1282    }
1283
1284    #[test]
1285    fn edge_zero_modal_height_y_offset() {
1286        assert_eq!(ModalEntranceAnimation::SlideDown.initial_y_offset(0), 0);
1287        assert_eq!(ModalEntranceAnimation::SlideUp.initial_y_offset(0), 0);
1288        assert_eq!(ModalExitAnimation::SlideUp.final_y_offset(0), 0);
1289        assert_eq!(ModalExitAnimation::SlideDown.final_y_offset(0), 0);
1290    }
1291
1292    #[test]
1293    fn edge_config_builder_methods() {
1294        let config = ModalAnimationConfig::new()
1295            .entrance(ModalEntranceAnimation::SlideDown)
1296            .exit(ModalExitAnimation::SlideUp)
1297            .entrance_duration(Duration::from_millis(300))
1298            .exit_duration(Duration::from_millis(200))
1299            .entrance_easing(ModalEasing::Back)
1300            .exit_easing(ModalEasing::EaseInOut)
1301            .min_scale(0.8)
1302            .animate_backdrop(false)
1303            .backdrop_duration(Duration::from_millis(50))
1304            .respect_reduced_motion(false);
1305
1306        assert_eq!(config.entrance, ModalEntranceAnimation::SlideDown);
1307        assert_eq!(config.exit, ModalExitAnimation::SlideUp);
1308        assert_eq!(config.entrance_duration, Duration::from_millis(300));
1309        assert_eq!(config.exit_duration, Duration::from_millis(200));
1310        assert_eq!(config.entrance_easing, ModalEasing::Back);
1311        assert_eq!(config.exit_easing, ModalEasing::EaseInOut);
1312        assert!((config.min_scale - 0.8).abs() < 1e-10);
1313        assert!(!config.animate_backdrop);
1314        assert_eq!(config.backdrop_duration, Duration::from_millis(50));
1315        assert!(!config.respect_reduced_motion);
1316    }
1317
1318    #[test]
1319    fn edge_min_scale_clamped() {
1320        // Below 0.5 → clamped to 0.5
1321        let config = ModalAnimationConfig::new().min_scale(0.1);
1322        assert!((config.min_scale - 0.5).abs() < 1e-10);
1323
1324        // Above 1.0 → clamped to 1.0
1325        let config = ModalAnimationConfig::new().min_scale(1.5);
1326        assert!((config.min_scale - 1.0).abs() < 1e-10);
1327
1328        // Normal value passes through
1329        let config = ModalAnimationConfig::new().min_scale(0.75);
1330        assert!((config.min_scale - 0.75).abs() < 1e-10);
1331    }
1332
1333    #[test]
1334    fn edge_is_disabled() {
1335        let config = ModalAnimationConfig::none();
1336        assert!(config.is_disabled());
1337
1338        let config = ModalAnimationConfig::default();
1339        assert!(!config.is_disabled());
1340
1341        // Only entrance None but exit not → not disabled
1342        let config = ModalAnimationConfig::new()
1343            .entrance(ModalEntranceAnimation::None)
1344            .exit(ModalExitAnimation::FadeOut);
1345        assert!(!config.is_disabled());
1346    }
1347
1348    #[test]
1349    fn edge_effective_without_reduced_motion() {
1350        let config = ModalAnimationConfig::default();
1351        let eff = config.effective(false);
1352        // Should return a clone of the original config
1353        assert_eq!(eff.entrance, ModalEntranceAnimation::ScaleIn);
1354        assert_eq!(eff.exit, ModalExitAnimation::ScaleOut);
1355    }
1356
1357    #[test]
1358    fn edge_effective_with_reduced_motion_but_not_respected() {
1359        let config = ModalAnimationConfig::default().respect_reduced_motion(false);
1360        let eff = config.effective(true);
1361        // respect_reduced_motion=false → should NOT apply reduced motion
1362        assert_eq!(eff.entrance, ModalEntranceAnimation::ScaleIn);
1363    }
1364
1365    #[test]
1366    fn edge_current_values_helper() {
1367        let state = ModalAnimationState::open();
1368        let config = ModalAnimationConfig::default();
1369        let (scale, opacity, backdrop, y_offset) = state.current_values(&config, 20);
1370        assert!((scale - 1.0).abs() < 1e-10);
1371        assert!((opacity - 1.0).abs() < 1e-10);
1372        assert!((backdrop - 1.0).abs() < 1e-10);
1373        assert_eq!(y_offset, 0);
1374    }
1375
1376    #[test]
1377    fn edge_current_values_closed() {
1378        let state = ModalAnimationState::new();
1379        let config = ModalAnimationConfig::default();
1380        let (scale, opacity, backdrop, y_offset) = state.current_values(&config, 20);
1381        assert!((scale - config.min_scale).abs() < 1e-10);
1382        assert!(opacity.abs() < 1e-10);
1383        assert!(backdrop.abs() < 1e-10);
1384        assert_eq!(y_offset, 0);
1385    }
1386
1387    #[test]
1388    fn edge_tick_noop_on_open() {
1389        let mut state = ModalAnimationState::open();
1390        let config = ModalAnimationConfig::default();
1391        let changed = state.tick(Duration::from_millis(100), &config);
1392        assert!(!changed);
1393        assert_eq!(state.phase(), ModalAnimationPhase::Open);
1394    }
1395
1396    #[test]
1397    fn edge_tick_noop_on_closed() {
1398        let mut state = ModalAnimationState::new();
1399        let config = ModalAnimationConfig::default();
1400        let changed = state.tick(Duration::from_millis(100), &config);
1401        assert!(!changed);
1402        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
1403    }
1404
1405    #[test]
1406    fn edge_tick_returns_false_mid_animation() {
1407        let mut state = ModalAnimationState::new();
1408        let config = ModalAnimationConfig::default();
1409        state.start_opening();
1410        // Small tick that won't complete the 200ms animation
1411        let changed = state.tick(Duration::from_millis(50), &config);
1412        assert!(!changed);
1413        assert_eq!(state.phase(), ModalAnimationPhase::Opening);
1414    }
1415
1416    #[test]
1417    fn edge_closing_animation_completes_to_closed() {
1418        let mut state = ModalAnimationState::open();
1419        let config = ModalAnimationConfig::default();
1420        state.start_closing();
1421        let changed = state.tick(Duration::from_secs(1), &config);
1422        assert!(changed);
1423        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
1424        assert_eq!(state.progress(), 0.0);
1425        assert_eq!(state.backdrop_progress(), 0.0);
1426    }
1427
1428    #[test]
1429    fn edge_start_opening_when_open_is_noop() {
1430        let mut state = ModalAnimationState::open();
1431        state.start_opening();
1432        assert_eq!(state.phase(), ModalAnimationPhase::Open);
1433        assert_eq!(state.progress(), 1.0);
1434    }
1435
1436    #[test]
1437    fn edge_start_closing_when_closed_is_noop() {
1438        let mut state = ModalAnimationState::new();
1439        state.start_closing();
1440        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
1441        assert_eq!(state.progress(), 0.0);
1442    }
1443
1444    #[test]
1445    fn edge_default_state_equals_new() {
1446        let default = ModalAnimationState::default();
1447        let new = ModalAnimationState::new();
1448        assert_eq!(default.phase(), new.phase());
1449        assert_eq!(default.progress(), new.progress());
1450        assert_eq!(default.backdrop_progress(), new.backdrop_progress());
1451    }
1452
1453    #[test]
1454    fn edge_backdrop_no_animation() {
1455        let mut state = ModalAnimationState::new();
1456        let config = ModalAnimationConfig::default().animate_backdrop(false);
1457        state.start_opening();
1458
1459        // With animate_backdrop=false, backdrop should be 1.0 during Opening
1460        let backdrop = state.current_backdrop_opacity(&config);
1461        assert!((backdrop - 1.0).abs() < 1e-10);
1462
1463        // Force to closing
1464        state.force_open();
1465        state.start_closing();
1466        let backdrop = state.current_backdrop_opacity(&config);
1467        assert!(backdrop.abs() < 1e-10);
1468    }
1469
1470    #[test]
1471    fn edge_entrance_scale_at_progress_clamped() {
1472        let config = ModalAnimationConfig::default();
1473        // Progress values outside [0, 1] should be clamped
1474        let s = ModalEntranceAnimation::ScaleIn.scale_at_progress(-0.5, &config);
1475        assert!((s - config.min_scale).abs() < 1e-10);
1476        let s = ModalEntranceAnimation::ScaleIn.scale_at_progress(2.0, &config);
1477        assert!((s - 1.0).abs() < 1e-10);
1478    }
1479
1480    #[test]
1481    fn edge_entrance_opacity_at_progress_clamped() {
1482        let o = ModalEntranceAnimation::FadeIn.opacity_at_progress(-1.0);
1483        assert!(o.abs() < 1e-10);
1484        let o = ModalEntranceAnimation::FadeIn.opacity_at_progress(5.0);
1485        assert!((o - 1.0).abs() < 1e-10);
1486    }
1487
1488    #[test]
1489    fn edge_entrance_y_offset_at_progress_clamped() {
1490        // At progress < 0 → clamped to 0 → full initial offset
1491        let y = ModalEntranceAnimation::SlideDown.y_offset_at_progress(-1.0, 20);
1492        assert_eq!(y, ModalEntranceAnimation::SlideDown.initial_y_offset(20));
1493        // At progress > 1 → clamped to 1 → offset 0
1494        let y = ModalEntranceAnimation::SlideDown.y_offset_at_progress(5.0, 20);
1495        assert_eq!(y, 0);
1496    }
1497
1498    #[test]
1499    fn edge_phase_default_is_closed() {
1500        assert_eq!(ModalAnimationPhase::default(), ModalAnimationPhase::Closed);
1501    }
1502
1503    #[test]
1504    fn edge_entrance_default_is_scale_in() {
1505        assert_eq!(
1506            ModalEntranceAnimation::default(),
1507            ModalEntranceAnimation::ScaleIn
1508        );
1509    }
1510
1511    #[test]
1512    fn edge_exit_default_is_scale_out() {
1513        assert_eq!(ModalExitAnimation::default(), ModalExitAnimation::ScaleOut);
1514    }
1515
1516    #[test]
1517    fn edge_easing_default_is_ease_out() {
1518        assert_eq!(ModalEasing::default(), ModalEasing::EaseOut);
1519    }
1520
1521    #[test]
1522    fn edge_config_none_fields() {
1523        let config = ModalAnimationConfig::none();
1524        assert_eq!(config.entrance, ModalEntranceAnimation::None);
1525        assert_eq!(config.exit, ModalExitAnimation::None);
1526        assert_eq!(config.entrance_duration, Duration::ZERO);
1527        assert_eq!(config.exit_duration, Duration::ZERO);
1528        assert_eq!(config.backdrop_duration, Duration::ZERO);
1529    }
1530
1531    #[test]
1532    fn edge_state_is_visible_is_closed_is_open() {
1533        let mut state = ModalAnimationState::new();
1534        assert!(!state.is_visible());
1535        assert!(state.is_closed());
1536        assert!(!state.is_open());
1537        assert!(!state.is_animating());
1538
1539        state.start_opening();
1540        assert!(state.is_visible());
1541        assert!(!state.is_closed());
1542        assert!(!state.is_open());
1543        assert!(state.is_animating());
1544
1545        state.force_open();
1546        assert!(state.is_visible());
1547        assert!(!state.is_closed());
1548        assert!(state.is_open());
1549        assert!(!state.is_animating());
1550    }
1551
1552    #[test]
1553    fn edge_force_open_during_closing() {
1554        let mut state = ModalAnimationState::open();
1555        state.start_closing();
1556        let config = ModalAnimationConfig::default();
1557        state.tick(Duration::from_millis(50), &config);
1558        assert_eq!(state.phase(), ModalAnimationPhase::Closing);
1559
1560        state.force_open();
1561        assert_eq!(state.phase(), ModalAnimationPhase::Open);
1562        assert_eq!(state.progress(), 1.0);
1563    }
1564
1565    #[test]
1566    fn edge_force_close_during_opening() {
1567        let mut state = ModalAnimationState::new();
1568        state.start_opening();
1569        let config = ModalAnimationConfig::default();
1570        state.tick(Duration::from_millis(50), &config);
1571
1572        state.force_close();
1573        assert_eq!(state.phase(), ModalAnimationPhase::Closed);
1574        assert_eq!(state.progress(), 0.0);
1575    }
1576
1577    #[test]
1578    fn edge_eased_progress_open_closed() {
1579        let config = ModalAnimationConfig::default();
1580        let state_open = ModalAnimationState::open();
1581        assert!((state_open.eased_progress(&config) - 1.0).abs() < 1e-10);
1582
1583        let state_closed = ModalAnimationState::new();
1584        assert!(state_closed.eased_progress(&config).abs() < 1e-10);
1585    }
1586
1587    #[test]
1588    fn edge_eased_backdrop_progress_open_closed() {
1589        let config = ModalAnimationConfig::default();
1590        let state_open = ModalAnimationState::open();
1591        assert!((state_open.eased_backdrop_progress(&config) - 1.0).abs() < 1e-10);
1592
1593        let state_closed = ModalAnimationState::new();
1594        assert!(state_closed.eased_backdrop_progress(&config).abs() < 1e-10);
1595    }
1596
1597    #[test]
1598    fn edge_clone_debug_phase() {
1599        let phase = ModalAnimationPhase::Opening;
1600        let cloned = phase;
1601        assert_eq!(cloned, ModalAnimationPhase::Opening);
1602        let _ = format!("{phase:?}");
1603    }
1604
1605    #[test]
1606    fn edge_clone_debug_entrance() {
1607        let anim = ModalEntranceAnimation::SlideDown;
1608        let cloned = anim;
1609        assert_eq!(cloned, ModalEntranceAnimation::SlideDown);
1610        let _ = format!("{anim:?}");
1611    }
1612
1613    #[test]
1614    fn edge_clone_debug_exit() {
1615        let anim = ModalExitAnimation::SlideUp;
1616        let cloned = anim;
1617        assert_eq!(cloned, ModalExitAnimation::SlideUp);
1618        let _ = format!("{anim:?}");
1619    }
1620
1621    #[test]
1622    fn edge_clone_debug_easing() {
1623        let easing = ModalEasing::Back;
1624        let _ = format!("{easing:?}");
1625        // PartialEq
1626        assert_eq!(easing, ModalEasing::Back);
1627        assert_ne!(easing, ModalEasing::Linear);
1628    }
1629
1630    #[test]
1631    fn edge_clone_debug_config() {
1632        let config = ModalAnimationConfig::default();
1633        let cloned = config.clone();
1634        assert_eq!(cloned.entrance, config.entrance);
1635        assert_eq!(cloned.exit, config.exit);
1636        let _ = format!("{config:?}");
1637    }
1638
1639    #[test]
1640    fn edge_clone_debug_state() {
1641        let mut state = ModalAnimationState::new();
1642        state.start_opening();
1643        let cloned = state.clone();
1644        assert_eq!(cloned.phase(), state.phase());
1645        assert_eq!(cloned.progress(), state.progress());
1646        let _ = format!("{state:?}");
1647    }
1648}