1#![forbid(unsafe_code)]
2
3use std::time::Duration;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum ModalAnimationPhase {
50 #[default]
52 Closed,
53 Opening,
55 Open,
57 Closing,
59}
60
61impl ModalAnimationPhase {
62 pub fn is_visible(self) -> bool {
64 !matches!(self, Self::Closed)
65 }
66
67 pub fn is_animating(self) -> bool {
69 matches!(self, Self::Opening | Self::Closing)
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum ModalEntranceAnimation {
80 #[default]
82 ScaleIn,
83 FadeIn,
85 SlideDown,
87 SlideUp,
89 None,
91}
92
93impl ModalEntranceAnimation {
94 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ModalExitAnimation {
151 #[default]
153 ScaleOut,
154 FadeOut,
156 SlideUp,
158 SlideDown,
160 None,
162}
163
164impl ModalExitAnimation {
165 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 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, }
179 }
180
181 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Default)]
222pub enum ModalEasing {
223 Linear,
225 #[default]
227 EaseOut,
228 EaseIn,
230 EaseInOut,
232 Back,
234}
235
236impl ModalEasing {
237 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 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 pub fn can_overshoot(self) -> bool {
267 matches!(self, Self::Back)
268 }
269}
270
271#[derive(Debug, Clone)]
277pub struct ModalAnimationConfig {
278 pub entrance: ModalEntranceAnimation,
280 pub exit: ModalExitAnimation,
282 pub entrance_duration: Duration,
284 pub exit_duration: Duration,
286 pub entrance_easing: ModalEasing,
288 pub exit_easing: ModalEasing,
290 pub min_scale: f64,
292 pub animate_backdrop: bool,
294 pub backdrop_duration: Duration,
296 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 pub fn new() -> Self {
320 Self::default()
321 }
322
323 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 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 pub fn entrance(mut self, anim: ModalEntranceAnimation) -> Self {
355 self.entrance = anim;
356 self
357 }
358
359 pub fn exit(mut self, anim: ModalExitAnimation) -> Self {
361 self.exit = anim;
362 self
363 }
364
365 pub fn entrance_duration(mut self, duration: Duration) -> Self {
367 self.entrance_duration = duration;
368 self
369 }
370
371 pub fn exit_duration(mut self, duration: Duration) -> Self {
373 self.exit_duration = duration;
374 self
375 }
376
377 pub fn entrance_easing(mut self, easing: ModalEasing) -> Self {
379 self.entrance_easing = easing;
380 self
381 }
382
383 pub fn exit_easing(mut self, easing: ModalEasing) -> Self {
385 self.exit_easing = easing;
386 self
387 }
388
389 pub fn min_scale(mut self, scale: f64) -> Self {
391 self.min_scale = scale.clamp(0.5, 1.0);
392 self
393 }
394
395 pub fn animate_backdrop(mut self, animate: bool) -> Self {
397 self.animate_backdrop = animate;
398 self
399 }
400
401 pub fn backdrop_duration(mut self, duration: Duration) -> Self {
403 self.backdrop_duration = duration;
404 self
405 }
406
407 pub fn respect_reduced_motion(mut self, respect: bool) -> Self {
409 self.respect_reduced_motion = respect;
410 self
411 }
412
413 pub fn is_disabled(&self) -> bool {
415 matches!(self.entrance, ModalEntranceAnimation::None)
416 && matches!(self.exit, ModalExitAnimation::None)
417 }
418
419 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#[derive(Debug, Clone)]
438pub struct ModalAnimationState {
439 phase: ModalAnimationPhase,
441 progress: f64,
443 backdrop_progress: f64,
445 reduced_motion: bool,
447}
448
449impl Default for ModalAnimationState {
450 fn default() -> Self {
451 Self::new()
452 }
453}
454
455impl ModalAnimationState {
456 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 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 pub fn phase(&self) -> ModalAnimationPhase {
478 self.phase
479 }
480
481 pub fn progress(&self) -> f64 {
483 self.progress
484 }
485
486 pub fn backdrop_progress(&self) -> f64 {
488 self.backdrop_progress
489 }
490
491 pub fn is_visible(&self) -> bool {
493 self.phase.is_visible()
494 }
495
496 pub fn is_animating(&self) -> bool {
498 self.phase.is_animating()
499 }
500
501 pub fn is_open(&self) -> bool {
503 matches!(self.phase, ModalAnimationPhase::Open)
504 }
505
506 pub fn is_closed(&self) -> bool {
508 matches!(self.phase, ModalAnimationPhase::Closed)
509 }
510
511 pub fn set_reduced_motion(&mut self, enabled: bool) {
513 self.reduced_motion = enabled;
514 }
515
516 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 self.phase = ModalAnimationPhase::Opening;
530 self.progress = 1.0 - self.progress;
532 self.backdrop_progress = 1.0 - self.backdrop_progress;
533 }
534 ModalAnimationPhase::Opening | ModalAnimationPhase::Open => {
535 }
537 }
538 }
539
540 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 self.phase = ModalAnimationPhase::Closing;
554 self.progress = 1.0 - self.progress;
556 self.backdrop_progress = 1.0 - self.backdrop_progress;
557 }
558 ModalAnimationPhase::Closing | ModalAnimationPhase::Closed => {
559 }
561 }
562 }
563
564 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 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 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 if content_duration > 0.0 {
596 self.progress += delta_secs / content_duration;
597 } else {
598 self.progress = 1.0;
599 }
600
601 if backdrop_duration > 0.0 {
603 self.backdrop_progress += delta_secs / backdrop_duration;
604 } else {
605 self.backdrop_progress = 1.0;
606 }
607
608 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 if content_duration > 0.0 {
629 self.progress += delta_secs / content_duration;
630 } else {
631 self.progress = 1.0;
632 }
633
634 if backdrop_duration > 0.0 {
636 self.backdrop_progress += delta_secs / backdrop_duration;
637 } else {
638 self.backdrop_progress = 1.0;
639 }
640
641 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 }
655 }
656
657 false
658 }
659
660 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 pub fn eased_backdrop_progress(&self, config: &ModalAnimationConfig) -> f64 {
673 let _config = config.effective(self.reduced_motion);
674 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 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 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 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 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 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#[cfg(test)]
774mod tests {
775 use super::*;
776
777 #[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 state.start_opening();
824 state.tick(Duration::from_millis(100), &config); let opening_progress = state.progress();
827 assert!(opening_progress > 0.0);
828 assert!(opening_progress < 1.0);
829
830 state.start_closing();
832 assert_eq!(state.phase(), ModalAnimationPhase::Closing);
833
834 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(); assert_eq!(state.progress(), progress1);
847 assert_eq!(state.phase(), ModalAnimationPhase::Opening);
848 }
849
850 #[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 #[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 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 let linear = ModalEasing::Linear.apply(0.5);
921 let ease_in = ModalEasing::EaseIn.apply(0.5);
922 assert!(ease_in < linear);
923 }
924
925 #[test]
930 fn test_scale_during_opening() {
931 let mut state = ModalAnimationState::new();
932 let config = ModalAnimationConfig::default();
933
934 let scale = state.current_scale(&config);
936 assert!((scale - config.min_scale).abs() < 0.001);
937
938 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 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 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
958
959 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 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 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 assert!(backdrop_opacity > content_opacity);
989 }
990
991 #[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); }
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 assert!((scale - 1.0).abs() < 0.001);
1016 }
1017
1018 #[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 #[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); }
1065
1066 #[test]
1067 fn test_slide_up_y_offset() {
1068 let initial = ModalEntranceAnimation::SlideUp.initial_y_offset(20);
1069 assert!(initial > 0); }
1071
1072 #[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 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}