1#![forbid(unsafe_code)]
2
3use web_time::{Duration, Instant};
23
24use crate::{Widget, set_style_area};
25use ftui_core::geometry::Rect;
26use ftui_render::cell::Cell;
27use ftui_render::frame::Frame;
28use ftui_style::Style;
29use ftui_text::display_width;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct ToastId(pub u64);
34
35impl ToastId {
36 pub fn new(id: u64) -> Self {
38 Self(id)
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ToastPosition {
45 TopLeft,
47 TopCenter,
49 #[default]
51 TopRight,
52 BottomLeft,
54 BottomCenter,
56 BottomRight,
58}
59
60impl ToastPosition {
61 pub fn calculate_position(
65 self,
66 terminal_width: u16,
67 terminal_height: u16,
68 toast_width: u16,
69 toast_height: u16,
70 margin: u16,
71 ) -> (u16, u16) {
72 let x = match self {
73 Self::TopLeft | Self::BottomLeft => margin,
74 Self::TopCenter | Self::BottomCenter => terminal_width.saturating_sub(toast_width) / 2,
75 Self::TopRight | Self::BottomRight => terminal_width
76 .saturating_sub(toast_width)
77 .saturating_sub(margin),
78 };
79
80 let y = match self {
81 Self::TopLeft | Self::TopCenter | Self::TopRight => margin,
82 Self::BottomLeft | Self::BottomCenter | Self::BottomRight => terminal_height
83 .saturating_sub(toast_height)
84 .saturating_sub(margin),
85 };
86
87 (x, y)
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum ToastIcon {
94 Success,
96 Error,
98 Warning,
100 #[default]
102 Info,
103 Custom(char),
105}
106
107impl ToastIcon {
108 pub fn as_char(self) -> char {
110 match self {
111 Self::Success => '\u{2713}', Self::Error => '\u{2717}', Self::Warning => '!',
114 Self::Info => 'i',
115 Self::Custom(c) => c,
116 }
117 }
118
119 pub fn as_ascii(self) -> char {
121 match self {
122 Self::Success => '+',
123 Self::Error => 'x',
124 Self::Warning => '!',
125 Self::Info => 'i',
126 Self::Custom(c) if c.is_ascii() => c,
127 Self::Custom(_) => '*',
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum ToastStyle {
135 Success,
137 Error,
139 Warning,
141 #[default]
143 Info,
144 Neutral,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
157pub enum ToastAnimationPhase {
158 Entering,
160 #[default]
162 Visible,
163 Exiting,
165 Hidden,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
173pub enum ToastEntranceAnimation {
174 SlideFromTop,
176 #[default]
178 SlideFromRight,
179 SlideFromBottom,
181 SlideFromLeft,
183 FadeIn,
185 None,
187}
188
189impl ToastEntranceAnimation {
190 pub fn initial_offset(self, toast_width: u16, toast_height: u16) -> (i16, i16) {
194 match self {
195 Self::SlideFromTop => (0, -(toast_height as i16)),
196 Self::SlideFromRight => (toast_width as i16, 0),
197 Self::SlideFromBottom => (0, toast_height as i16),
198 Self::SlideFromLeft => (-(toast_width as i16), 0),
199 Self::FadeIn | Self::None => (0, 0),
200 }
201 }
202
203 pub fn offset_at_progress(
207 self,
208 progress: f64,
209 toast_width: u16,
210 toast_height: u16,
211 ) -> (i16, i16) {
212 let (dx, dy) = self.initial_offset(toast_width, toast_height);
213 let inv_progress = 1.0 - progress.clamp(0.0, 1.0);
214 (
215 (dx as f64 * inv_progress).round() as i16,
216 (dy as f64 * inv_progress).round() as i16,
217 )
218 }
219
220 pub fn affects_position(self) -> bool {
222 !matches!(self, Self::FadeIn | Self::None)
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub enum ToastExitAnimation {
231 #[default]
233 FadeOut,
234 SlideOut,
236 SlideToTop,
238 SlideToRight,
239 SlideToBottom,
240 SlideToLeft,
241 None,
243}
244
245impl ToastExitAnimation {
246 pub fn final_offset(
250 self,
251 toast_width: u16,
252 toast_height: u16,
253 entrance: ToastEntranceAnimation,
254 ) -> (i16, i16) {
255 match self {
256 Self::SlideOut => {
257 let (dx, dy) = entrance.initial_offset(toast_width, toast_height);
259 (-dx, -dy)
260 }
261 Self::SlideToTop => (0, -(toast_height as i16)),
262 Self::SlideToRight => (toast_width as i16, 0),
263 Self::SlideToBottom => (0, toast_height as i16),
264 Self::SlideToLeft => (-(toast_width as i16), 0),
265 Self::FadeOut | Self::None => (0, 0),
266 }
267 }
268
269 pub fn offset_at_progress(
273 self,
274 progress: f64,
275 toast_width: u16,
276 toast_height: u16,
277 entrance: ToastEntranceAnimation,
278 ) -> (i16, i16) {
279 let (dx, dy) = self.final_offset(toast_width, toast_height, entrance);
280 let p = progress.clamp(0.0, 1.0);
281 (
282 (dx as f64 * p).round() as i16,
283 (dy as f64 * p).round() as i16,
284 )
285 }
286
287 pub fn affects_position(self) -> bool {
289 !matches!(self, Self::FadeOut | Self::None)
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Default)]
298pub enum ToastEasing {
299 Linear,
301 #[default]
303 EaseOut,
304 EaseIn,
306 EaseInOut,
308 Bounce,
310}
311
312impl ToastEasing {
313 pub fn apply(self, t: f64) -> f64 {
315 let t = t.clamp(0.0, 1.0);
316 match self {
317 Self::Linear => t,
318 Self::EaseOut => {
319 let inv = 1.0 - t;
320 1.0 - inv * inv * inv
321 }
322 Self::EaseIn => t * t * t,
323 Self::EaseInOut => {
324 if t < 0.5 {
325 4.0 * t * t * t
326 } else {
327 let inv = -2.0 * t + 2.0;
328 1.0 - inv * inv * inv / 2.0
329 }
330 }
331 Self::Bounce => {
332 let n1 = 7.5625;
333 let d1 = 2.75;
334 let mut t = t;
335 if t < 1.0 / d1 {
336 n1 * t * t
337 } else if t < 2.0 / d1 {
338 t -= 1.5 / d1;
339 n1 * t * t + 0.75
340 } else if t < 2.5 / d1 {
341 t -= 2.25 / d1;
342 n1 * t * t + 0.9375
343 } else {
344 t -= 2.625 / d1;
345 n1 * t * t + 0.984375
346 }
347 }
348 }
349 }
350}
351
352#[derive(Debug, Clone)]
354pub struct ToastAnimationConfig {
355 pub entrance: ToastEntranceAnimation,
357 pub exit: ToastExitAnimation,
359 pub entrance_duration: Duration,
361 pub exit_duration: Duration,
363 pub entrance_easing: ToastEasing,
365 pub exit_easing: ToastEasing,
367 pub respect_reduced_motion: bool,
369}
370
371impl Default for ToastAnimationConfig {
372 fn default() -> Self {
373 Self {
374 entrance: ToastEntranceAnimation::default(),
375 exit: ToastExitAnimation::default(),
376 entrance_duration: Duration::from_millis(200),
377 exit_duration: Duration::from_millis(150),
378 entrance_easing: ToastEasing::EaseOut,
379 exit_easing: ToastEasing::EaseIn,
380 respect_reduced_motion: true,
381 }
382 }
383}
384
385impl ToastAnimationConfig {
386 pub fn none() -> Self {
388 Self {
389 entrance: ToastEntranceAnimation::None,
390 exit: ToastExitAnimation::None,
391 entrance_duration: Duration::ZERO,
392 exit_duration: Duration::ZERO,
393 ..Default::default()
394 }
395 }
396
397 pub fn is_disabled(&self) -> bool {
399 matches!(self.entrance, ToastEntranceAnimation::None)
400 && matches!(self.exit, ToastExitAnimation::None)
401 }
402}
403
404#[derive(Debug, Clone)]
406pub struct ToastAnimationState {
407 pub phase: ToastAnimationPhase,
409 pub phase_started: Instant,
411 pub reduced_motion: bool,
413}
414
415impl Default for ToastAnimationState {
416 fn default() -> Self {
417 Self {
418 phase: ToastAnimationPhase::Entering,
419 phase_started: Instant::now(),
420 reduced_motion: false,
421 }
422 }
423}
424
425impl ToastAnimationState {
426 pub fn new() -> Self {
428 Self::default()
429 }
430
431 pub fn with_reduced_motion() -> Self {
433 Self {
434 phase: ToastAnimationPhase::Visible,
435 phase_started: Instant::now(),
436 reduced_motion: true,
437 }
438 }
439
440 pub fn progress(&self, phase_duration: Duration) -> f64 {
442 if phase_duration.is_zero() {
443 return 1.0;
444 }
445 let elapsed = self.phase_started.elapsed();
446 (elapsed.as_secs_f64() / phase_duration.as_secs_f64()).min(1.0)
447 }
448
449 pub fn transition_to(&mut self, phase: ToastAnimationPhase) {
451 self.phase = phase;
452 self.phase_started = Instant::now();
453 }
454
455 pub fn start_exit(&mut self) {
457 if self.reduced_motion {
458 self.transition_to(ToastAnimationPhase::Hidden);
459 } else {
460 self.transition_to(ToastAnimationPhase::Exiting);
461 }
462 }
463
464 pub fn is_complete(&self) -> bool {
466 self.phase == ToastAnimationPhase::Hidden
467 }
468
469 pub fn tick(&mut self, config: &ToastAnimationConfig) -> bool {
473 let prev_phase = self.phase;
474
475 match self.phase {
476 ToastAnimationPhase::Entering => {
477 let duration = if self.reduced_motion {
478 Duration::ZERO
479 } else {
480 config.entrance_duration
481 };
482 if self.progress(duration) >= 1.0 {
483 self.transition_to(ToastAnimationPhase::Visible);
484 }
485 }
486 ToastAnimationPhase::Exiting => {
487 let duration = if self.reduced_motion {
488 Duration::ZERO
489 } else {
490 config.exit_duration
491 };
492 if self.progress(duration) >= 1.0 {
493 self.transition_to(ToastAnimationPhase::Hidden);
494 }
495 }
496 ToastAnimationPhase::Visible | ToastAnimationPhase::Hidden => {}
497 }
498
499 self.phase != prev_phase
500 }
501
502 pub fn current_offset(
506 &self,
507 config: &ToastAnimationConfig,
508 toast_width: u16,
509 toast_height: u16,
510 ) -> (i16, i16) {
511 if self.reduced_motion {
512 return (0, 0);
513 }
514
515 match self.phase {
516 ToastAnimationPhase::Entering => {
517 let raw_progress = self.progress(config.entrance_duration);
518 let eased_progress = config.entrance_easing.apply(raw_progress);
519 config
520 .entrance
521 .offset_at_progress(eased_progress, toast_width, toast_height)
522 }
523 ToastAnimationPhase::Exiting => {
524 let raw_progress = self.progress(config.exit_duration);
525 let eased_progress = config.exit_easing.apply(raw_progress);
526 config.exit.offset_at_progress(
527 eased_progress,
528 toast_width,
529 toast_height,
530 config.entrance,
531 )
532 }
533 ToastAnimationPhase::Visible => (0, 0),
534 ToastAnimationPhase::Hidden => (0, 0),
535 }
536 }
537
538 pub fn current_opacity(&self, config: &ToastAnimationConfig) -> f64 {
542 if self.reduced_motion {
543 return if self.phase == ToastAnimationPhase::Hidden {
544 0.0
545 } else {
546 1.0
547 };
548 }
549
550 match self.phase {
551 ToastAnimationPhase::Entering => {
552 if matches!(config.entrance, ToastEntranceAnimation::FadeIn) {
553 let raw_progress = self.progress(config.entrance_duration);
554 config.entrance_easing.apply(raw_progress)
555 } else {
556 1.0
557 }
558 }
559 ToastAnimationPhase::Exiting => {
560 if matches!(config.exit, ToastExitAnimation::FadeOut) {
561 let raw_progress = self.progress(config.exit_duration);
562 1.0 - config.exit_easing.apply(raw_progress)
563 } else {
564 1.0
565 }
566 }
567 ToastAnimationPhase::Visible => 1.0,
568 ToastAnimationPhase::Hidden => 0.0,
569 }
570 }
571}
572
573#[derive(Debug, Clone)]
575pub struct ToastConfig {
576 pub position: ToastPosition,
578 pub duration: Option<Duration>,
580 pub style_variant: ToastStyle,
582 pub max_width: u16,
584 pub margin: u16,
586 pub dismissable: bool,
588 pub animation: ToastAnimationConfig,
590}
591
592impl Default for ToastConfig {
593 fn default() -> Self {
594 Self {
595 position: ToastPosition::default(),
596 duration: Some(Duration::from_secs(5)),
597 style_variant: ToastStyle::default(),
598 max_width: 50,
599 margin: 1,
600 dismissable: true,
601 animation: ToastAnimationConfig::default(),
602 }
603 }
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612pub enum KeyEvent {
613 Esc,
615 Tab,
617 Enter,
619 Other,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq)]
641pub struct ToastAction {
642 pub label: String,
644 pub id: String,
646}
647
648impl ToastAction {
649 pub fn new(label: impl Into<String>, id: impl Into<String>) -> Self {
655 let label = label.into();
656 let id = id.into();
657 debug_assert!(
658 !label.trim().is_empty(),
659 "ToastAction label must not be empty"
660 );
661 debug_assert!(!id.trim().is_empty(), "ToastAction id must not be empty");
662 Self { label, id }
663 }
664
665 pub fn display_width(&self) -> usize {
669 display_width(self.label.as_str()) + 2 }
671}
672
673#[derive(Debug, Clone, PartialEq, Eq)]
677pub enum ToastEvent {
678 None,
680 Dismissed,
682 Action(String),
684 FocusChanged,
686}
687
688#[derive(Debug, Clone)]
690pub struct ToastContent {
691 pub message: String,
693 pub icon: Option<ToastIcon>,
695 pub title: Option<String>,
697}
698
699impl ToastContent {
700 pub fn new(message: impl Into<String>) -> Self {
702 Self {
703 message: message.into(),
704 icon: None,
705 title: None,
706 }
707 }
708
709 #[must_use]
711 pub fn with_icon(mut self, icon: ToastIcon) -> Self {
712 self.icon = Some(icon);
713 self
714 }
715
716 #[must_use]
718 pub fn with_title(mut self, title: impl Into<String>) -> Self {
719 self.title = Some(title.into());
720 self
721 }
722}
723
724#[derive(Debug, Clone)]
726pub struct ToastState {
727 pub created_at: Instant,
729 pub dismissed: bool,
731 pub animation: ToastAnimationState,
733 pub focused_action: Option<usize>,
735 pub timer_paused: bool,
737 pub pause_started: Option<Instant>,
739 pub total_paused: Duration,
741}
742
743impl Default for ToastState {
744 fn default() -> Self {
745 Self {
746 created_at: Instant::now(),
747 dismissed: false,
748 animation: ToastAnimationState::default(),
749 focused_action: None,
750 timer_paused: false,
751 pause_started: None,
752 total_paused: Duration::ZERO,
753 }
754 }
755}
756
757impl ToastState {
758 pub fn with_reduced_motion() -> Self {
760 Self {
761 created_at: Instant::now(),
762 dismissed: false,
763 animation: ToastAnimationState::with_reduced_motion(),
764 focused_action: None,
765 timer_paused: false,
766 pause_started: None,
767 total_paused: Duration::ZERO,
768 }
769 }
770}
771
772#[derive(Debug, Clone)]
790pub struct Toast {
791 pub id: ToastId,
793 pub content: ToastContent,
795 pub config: ToastConfig,
797 pub state: ToastState,
799 pub actions: Vec<ToastAction>,
801 style: Style,
803 icon_style: Style,
805 title_style: Style,
807 action_style: Style,
809 action_focus_style: Style,
811}
812
813impl Toast {
814 pub fn new(message: impl Into<String>) -> Self {
816 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
817 let id = ToastId::new(NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
818
819 Self {
820 id,
821 content: ToastContent::new(message),
822 config: ToastConfig::default(),
823 state: ToastState::default(),
824 actions: Vec::new(),
825 style: Style::default(),
826 icon_style: Style::default(),
827 title_style: Style::default(),
828 action_style: Style::default(),
829 action_focus_style: Style::default(),
830 }
831 }
832
833 pub fn with_id(id: ToastId, message: impl Into<String>) -> Self {
835 Self {
836 id,
837 content: ToastContent::new(message),
838 config: ToastConfig::default(),
839 state: ToastState::default(),
840 actions: Vec::new(),
841 style: Style::default(),
842 icon_style: Style::default(),
843 title_style: Style::default(),
844 action_style: Style::default(),
845 action_focus_style: Style::default(),
846 }
847 }
848
849 #[must_use]
853 pub fn icon(mut self, icon: ToastIcon) -> Self {
854 self.content.icon = Some(icon);
855 self
856 }
857
858 #[must_use]
860 pub fn title(mut self, title: impl Into<String>) -> Self {
861 self.content.title = Some(title.into());
862 self
863 }
864
865 #[must_use]
867 pub fn position(mut self, position: ToastPosition) -> Self {
868 self.config.position = position;
869 self
870 }
871
872 #[must_use]
874 pub fn duration(mut self, duration: Duration) -> Self {
875 self.config.duration = Some(duration);
876 self
877 }
878
879 #[must_use]
881 pub fn persistent(mut self) -> Self {
882 self.config.duration = None;
883 self
884 }
885
886 #[must_use]
888 pub fn style_variant(mut self, variant: ToastStyle) -> Self {
889 self.config.style_variant = variant;
890 self
891 }
892
893 #[must_use]
895 pub fn max_width(mut self, width: u16) -> Self {
896 self.config.max_width = width;
897 self
898 }
899
900 #[must_use]
902 pub fn margin(mut self, margin: u16) -> Self {
903 self.config.margin = margin;
904 self
905 }
906
907 #[must_use]
909 pub fn dismissable(mut self, dismissable: bool) -> Self {
910 self.config.dismissable = dismissable;
911 self
912 }
913
914 #[must_use]
916 pub fn style(mut self, style: Style) -> Self {
917 self.style = style;
918 self
919 }
920
921 #[must_use]
923 pub fn with_icon_style(mut self, style: Style) -> Self {
924 self.icon_style = style;
925 self
926 }
927
928 #[must_use]
930 pub fn with_title_style(mut self, style: Style) -> Self {
931 self.title_style = style;
932 self
933 }
934
935 #[must_use]
939 pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
940 self.config.animation.entrance = animation;
941 self
942 }
943
944 #[must_use]
946 pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
947 self.config.animation.exit = animation;
948 self
949 }
950
951 #[must_use]
953 pub fn entrance_duration(mut self, duration: Duration) -> Self {
954 self.config.animation.entrance_duration = duration;
955 self
956 }
957
958 #[must_use]
960 pub fn exit_duration(mut self, duration: Duration) -> Self {
961 self.config.animation.exit_duration = duration;
962 self
963 }
964
965 #[must_use]
967 pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
968 self.config.animation.entrance_easing = easing;
969 self
970 }
971
972 #[must_use]
974 pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
975 self.config.animation.exit_easing = easing;
976 self
977 }
978
979 #[must_use]
983 pub fn action(mut self, action: ToastAction) -> Self {
984 self.actions.push(action);
985 self
986 }
987
988 #[must_use]
990 pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
991 self.actions = actions;
992 self
993 }
994
995 #[must_use]
997 pub fn with_action_style(mut self, style: Style) -> Self {
998 self.action_style = style;
999 self
1000 }
1001
1002 #[must_use]
1004 pub fn with_action_focus_style(mut self, style: Style) -> Self {
1005 self.action_focus_style = style;
1006 self
1007 }
1008
1009 #[must_use]
1011 pub fn no_animation(mut self) -> Self {
1012 self.config.animation = ToastAnimationConfig::none();
1013 self.state.animation = ToastAnimationState {
1014 phase: ToastAnimationPhase::Visible,
1015 phase_started: Instant::now(),
1016 reduced_motion: true,
1017 };
1018 self
1019 }
1020
1021 #[must_use]
1023 pub fn reduced_motion(mut self, enabled: bool) -> Self {
1024 self.config.animation.respect_reduced_motion = enabled;
1025 if enabled {
1026 self.state.animation = ToastAnimationState::with_reduced_motion();
1027 }
1028 self
1029 }
1030
1031 pub fn is_expired(&self) -> bool {
1037 if let Some(duration) = self.config.duration {
1038 let wall_elapsed = self.state.created_at.elapsed();
1039 let mut paused = self.state.total_paused;
1040 if self.state.timer_paused
1041 && let Some(pause_start) = self.state.pause_started
1042 {
1043 paused += pause_start.elapsed();
1044 }
1045 let effective_elapsed = wall_elapsed.saturating_sub(paused);
1046 effective_elapsed >= duration
1047 } else {
1048 false
1049 }
1050 }
1051
1052 #[inline]
1057 pub fn is_visible(&self) -> bool {
1058 !self.state.dismissed
1059 && !self.is_expired()
1060 && self.state.animation.phase != ToastAnimationPhase::Hidden
1061 }
1062
1063 pub fn is_animating(&self) -> bool {
1065 matches!(
1066 self.state.animation.phase,
1067 ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1068 )
1069 }
1070
1071 pub fn dismiss(&mut self) {
1073 if !self.state.dismissed {
1074 self.state.dismissed = true;
1075 self.state.animation.start_exit();
1076 }
1077 }
1078
1079 pub fn dismiss_immediately(&mut self) {
1081 self.state.dismissed = true;
1082 self.state
1083 .animation
1084 .transition_to(ToastAnimationPhase::Hidden);
1085 }
1086
1087 pub fn tick_animation(&mut self) -> bool {
1091 self.state.animation.tick(&self.config.animation)
1092 }
1093
1094 pub fn animation_phase(&self) -> ToastAnimationPhase {
1096 self.state.animation.phase
1097 }
1098
1099 pub fn animation_offset(&self) -> (i16, i16) {
1103 let (width, height) = self.calculate_dimensions();
1104 self.state
1105 .animation
1106 .current_offset(&self.config.animation, width, height)
1107 }
1108
1109 pub fn animation_opacity(&self) -> f64 {
1111 self.state.animation.current_opacity(&self.config.animation)
1112 }
1113
1114 #[must_use = "use the remaining time (if any) for scheduling"]
1118 pub fn remaining_time(&self) -> Option<Duration> {
1119 self.config.duration.map(|d| {
1120 let wall_elapsed = self.state.created_at.elapsed();
1121 let mut paused = self.state.total_paused;
1122 if self.state.timer_paused
1123 && let Some(pause_start) = self.state.pause_started
1124 {
1125 paused += pause_start.elapsed();
1126 }
1127 let effective_elapsed = wall_elapsed.saturating_sub(paused);
1128 d.saturating_sub(effective_elapsed)
1129 })
1130 }
1131
1132 pub fn handle_key(&mut self, key: KeyEvent) -> ToastEvent {
1141 if !self.is_visible() {
1142 return ToastEvent::None;
1143 }
1144
1145 match key {
1146 KeyEvent::Esc => {
1147 if self.has_focus() {
1148 self.clear_focus();
1149 ToastEvent::None
1150 } else if self.config.dismissable {
1151 self.dismiss();
1152 ToastEvent::Dismissed
1153 } else {
1154 ToastEvent::None
1155 }
1156 }
1157 KeyEvent::Tab => {
1158 if self.actions.is_empty() {
1159 return ToastEvent::None;
1160 }
1161 let next = match self.state.focused_action {
1162 None => 0,
1163 Some(i) => (i + 1) % self.actions.len(),
1164 };
1165 self.state.focused_action = Some(next);
1166 self.pause_timer();
1167 ToastEvent::FocusChanged
1168 }
1169 KeyEvent::Enter => {
1170 if let Some(idx) = self.state.focused_action
1171 && let Some(action) = self.actions.get(idx)
1172 {
1173 let id = action.id.clone();
1174 self.dismiss();
1175 return ToastEvent::Action(id);
1176 }
1177 ToastEvent::None
1178 }
1179 _ => ToastEvent::None,
1180 }
1181 }
1182
1183 pub fn pause_timer(&mut self) {
1185 if !self.state.timer_paused {
1186 self.state.timer_paused = true;
1187 self.state.pause_started = Some(Instant::now());
1188 }
1189 }
1190
1191 pub fn resume_timer(&mut self) {
1193 if self.state.timer_paused {
1194 if let Some(pause_start) = self.state.pause_started.take() {
1195 self.state.total_paused += pause_start.elapsed();
1196 }
1197 self.state.timer_paused = false;
1198 }
1199 }
1200
1201 pub fn clear_focus(&mut self) {
1203 self.state.focused_action = None;
1204 self.resume_timer();
1205 }
1206
1207 pub fn has_focus(&self) -> bool {
1209 self.state.focused_action.is_some()
1210 }
1211
1212 #[must_use = "use the focused action (if any)"]
1214 pub fn focused_action(&self) -> Option<&ToastAction> {
1215 self.state
1216 .focused_action
1217 .and_then(|idx| self.actions.get(idx))
1218 }
1219
1220 pub fn calculate_dimensions(&self) -> (u16, u16) {
1222 let max_width = self.config.max_width as usize;
1223
1224 let icon_width = self
1226 .content
1227 .icon
1228 .map(|icon| {
1229 let mut buf = [0u8; 4];
1230 let s = icon.as_char().encode_utf8(&mut buf);
1231 display_width(s) + 1
1232 })
1233 .unwrap_or(0); let message_width = display_width(self.content.message.as_str());
1235 let title_width = self
1236 .content
1237 .title
1238 .as_ref()
1239 .map(|t| display_width(t.as_str()))
1240 .unwrap_or(0);
1241
1242 let mut content_width = (icon_width + message_width).max(title_width);
1244
1245 if !self.actions.is_empty() {
1247 let actions_width: usize = self
1248 .actions
1249 .iter()
1250 .map(|a| a.display_width())
1251 .sum::<usize>()
1252 + self.actions.len().saturating_sub(1); content_width = content_width.max(actions_width);
1254 }
1255
1256 let total_width = content_width.saturating_add(4).min(max_width);
1258
1259 let has_title = self.content.title.is_some();
1261 let has_actions = !self.actions.is_empty();
1262 let height = 3 + u16::from(has_title) + u16::from(has_actions);
1263
1264 (total_width as u16, height)
1265 }
1266}
1267
1268impl Widget for Toast {
1269 fn render(&self, area: Rect, frame: &mut Frame) {
1270 #[cfg(feature = "tracing")]
1271 let _span = tracing::debug_span!(
1272 "widget_render",
1273 widget = "Toast",
1274 x = area.x,
1275 y = area.y,
1276 w = area.width,
1277 h = area.height
1278 )
1279 .entered();
1280
1281 if area.is_empty() || !self.is_visible() {
1282 return;
1283 }
1284
1285 let deg = frame.buffer.degradation;
1286
1287 let (content_width, content_height) = self.calculate_dimensions();
1289 let width = area.width.min(content_width);
1290 let height = area.height.min(content_height);
1291
1292 if width < 3 || height < 3 {
1293 return; }
1295
1296 let render_area = Rect::new(area.x, area.y, width, height);
1297
1298 if deg.apply_styling() {
1300 set_style_area(&mut frame.buffer, render_area, self.style);
1301 }
1302
1303 let use_unicode = deg.apply_styling();
1305 let (tl, tr, bl, br, h, v) = if use_unicode {
1306 (
1307 '\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{2500}', '\u{2502}',
1308 )
1309 } else {
1310 ('+', '+', '+', '+', '-', '|')
1311 };
1312
1313 if let Some(cell) = frame.buffer.get_mut(render_area.x, render_area.y) {
1315 *cell = Cell::from_char(tl);
1316 if deg.apply_styling() {
1317 crate::apply_style(cell, self.style);
1318 }
1319 }
1320 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1321 if let Some(cell) = frame.buffer.get_mut(x, render_area.y) {
1322 *cell = Cell::from_char(h);
1323 if deg.apply_styling() {
1324 crate::apply_style(cell, self.style);
1325 }
1326 }
1327 }
1328 if let Some(cell) = frame
1329 .buffer
1330 .get_mut(render_area.right().saturating_sub(1), render_area.y)
1331 {
1332 *cell = Cell::from_char(tr);
1333 if deg.apply_styling() {
1334 crate::apply_style(cell, self.style);
1335 }
1336 }
1337
1338 let bottom_y = render_area.bottom().saturating_sub(1);
1340 if let Some(cell) = frame.buffer.get_mut(render_area.x, bottom_y) {
1341 *cell = Cell::from_char(bl);
1342 if deg.apply_styling() {
1343 crate::apply_style(cell, self.style);
1344 }
1345 }
1346 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1347 if let Some(cell) = frame.buffer.get_mut(x, bottom_y) {
1348 *cell = Cell::from_char(h);
1349 if deg.apply_styling() {
1350 crate::apply_style(cell, self.style);
1351 }
1352 }
1353 }
1354 if let Some(cell) = frame
1355 .buffer
1356 .get_mut(render_area.right().saturating_sub(1), bottom_y)
1357 {
1358 *cell = Cell::from_char(br);
1359 if deg.apply_styling() {
1360 crate::apply_style(cell, self.style);
1361 }
1362 }
1363
1364 for y in (render_area.y + 1)..bottom_y {
1366 if let Some(cell) = frame.buffer.get_mut(render_area.x, y) {
1367 *cell = Cell::from_char(v);
1368 if deg.apply_styling() {
1369 crate::apply_style(cell, self.style);
1370 }
1371 }
1372 if let Some(cell) = frame
1373 .buffer
1374 .get_mut(render_area.right().saturating_sub(1), y)
1375 {
1376 *cell = Cell::from_char(v);
1377 if deg.apply_styling() {
1378 crate::apply_style(cell, self.style);
1379 }
1380 }
1381 }
1382
1383 let content_x = render_area.x + 1; let content_width = width.saturating_sub(2); let mut content_y = render_area.y + 1;
1387
1388 if let Some(ref title) = self.content.title {
1390 let title_style = if deg.apply_styling() {
1391 self.title_style.merge(&self.style)
1392 } else {
1393 Style::default()
1394 };
1395
1396 let title_style = if deg.apply_styling() {
1397 title_style
1398 } else {
1399 Style::default()
1400 };
1401 crate::draw_text_span(
1402 frame,
1403 content_x,
1404 content_y,
1405 title,
1406 title_style,
1407 content_x + content_width,
1408 );
1409 content_y += 1;
1410 }
1411
1412 let mut msg_x = content_x;
1414
1415 if let Some(icon) = self.content.icon {
1416 let icon_char = if use_unicode {
1417 icon.as_char()
1418 } else {
1419 icon.as_ascii()
1420 };
1421
1422 let icon_style = if deg.apply_styling() {
1423 self.icon_style.merge(&self.style)
1424 } else {
1425 Style::default()
1426 };
1427 let icon_str = icon_char.to_string();
1428 msg_x = crate::draw_text_span(
1429 frame,
1430 msg_x,
1431 content_y,
1432 &icon_str,
1433 icon_style,
1434 content_x + content_width,
1435 );
1436 msg_x = crate::draw_text_span(
1437 frame,
1438 msg_x,
1439 content_y,
1440 " ",
1441 Style::default(),
1442 content_x + content_width,
1443 );
1444 }
1445
1446 let msg_style = if deg.apply_styling() {
1448 self.style
1449 } else {
1450 Style::default()
1451 };
1452 crate::draw_text_span(
1453 frame,
1454 msg_x,
1455 content_y,
1456 &self.content.message,
1457 msg_style,
1458 content_x + content_width,
1459 );
1460
1461 if !self.actions.is_empty() {
1463 content_y += 1;
1464 let mut btn_x = content_x;
1465
1466 for (idx, action) in self.actions.iter().enumerate() {
1467 let is_focused = self.state.focused_action == Some(idx);
1468 let btn_style = if is_focused && deg.apply_styling() {
1469 self.action_focus_style.merge(&self.style)
1470 } else if deg.apply_styling() {
1471 self.action_style.merge(&self.style)
1472 } else {
1473 Style::default()
1474 };
1475
1476 let max_x = content_x + content_width;
1477 let label = format!("[{}]", action.label);
1478 btn_x = crate::draw_text_span(frame, btn_x, content_y, &label, btn_style, max_x);
1479
1480 if idx + 1 < self.actions.len() {
1482 btn_x = crate::draw_text_span(
1483 frame,
1484 btn_x,
1485 content_y,
1486 " ",
1487 Style::default(),
1488 max_x,
1489 );
1490 }
1491 }
1492 }
1493 }
1494
1495 fn is_essential(&self) -> bool {
1496 false
1498 }
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503 use super::*;
1504 use ftui_render::grapheme_pool::GraphemePool;
1505
1506 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
1507 frame
1508 .buffer
1509 .get(x, y)
1510 .copied()
1511 .expect("test cell should exist")
1512 }
1513
1514 fn focused_action_id(toast: &Toast) -> &str {
1515 toast
1516 .focused_action()
1517 .expect("focused action should exist")
1518 .id
1519 .as_str()
1520 }
1521
1522 fn unwrap_remaining(remaining: Option<Duration>) -> Duration {
1523 remaining.expect("remaining duration should exist")
1524 }
1525
1526 #[test]
1527 fn test_toast_new() {
1528 let toast = Toast::new("Hello");
1529 assert_eq!(toast.content.message, "Hello");
1530 assert!(toast.content.icon.is_none());
1531 assert!(toast.content.title.is_none());
1532 assert!(toast.is_visible());
1533 }
1534
1535 #[test]
1536 fn test_toast_builder() {
1537 let toast = Toast::new("Test message")
1538 .icon(ToastIcon::Success)
1539 .title("Success")
1540 .position(ToastPosition::BottomRight)
1541 .duration(Duration::from_secs(10))
1542 .max_width(60);
1543
1544 assert_eq!(toast.content.message, "Test message");
1545 assert_eq!(toast.content.icon, Some(ToastIcon::Success));
1546 assert_eq!(toast.content.title, Some("Success".to_string()));
1547 assert_eq!(toast.config.position, ToastPosition::BottomRight);
1548 assert_eq!(toast.config.duration, Some(Duration::from_secs(10)));
1549 assert_eq!(toast.config.max_width, 60);
1550 }
1551
1552 #[test]
1553 fn test_toast_persistent() {
1554 let toast = Toast::new("Persistent").persistent();
1555 assert!(toast.config.duration.is_none());
1556 assert!(!toast.is_expired());
1557 }
1558
1559 #[test]
1560 fn test_toast_dismiss() {
1561 let mut toast = Toast::new("Dismissable");
1562 assert!(toast.is_visible());
1563 toast.dismiss();
1564 assert!(!toast.is_visible());
1565 assert!(toast.state.dismissed);
1566 }
1567
1568 #[test]
1569 fn test_toast_position_calculate() {
1570 let terminal_width = 80;
1571 let terminal_height = 24;
1572 let toast_width = 30;
1573 let toast_height = 3;
1574 let margin = 1;
1575
1576 let (x, y) = ToastPosition::TopLeft.calculate_position(
1578 terminal_width,
1579 terminal_height,
1580 toast_width,
1581 toast_height,
1582 margin,
1583 );
1584 assert_eq!(x, 1);
1585 assert_eq!(y, 1);
1586
1587 let (x, y) = ToastPosition::TopRight.calculate_position(
1589 terminal_width,
1590 terminal_height,
1591 toast_width,
1592 toast_height,
1593 margin,
1594 );
1595 assert_eq!(x, 80 - 30 - 1); assert_eq!(y, 1);
1597
1598 let (x, y) = ToastPosition::BottomRight.calculate_position(
1600 terminal_width,
1601 terminal_height,
1602 toast_width,
1603 toast_height,
1604 margin,
1605 );
1606 assert_eq!(x, 49);
1607 assert_eq!(y, 24 - 3 - 1); let (x, y) = ToastPosition::TopCenter.calculate_position(
1611 terminal_width,
1612 terminal_height,
1613 toast_width,
1614 toast_height,
1615 margin,
1616 );
1617 assert_eq!(x, (80 - 30) / 2); assert_eq!(y, 1);
1619 }
1620
1621 #[test]
1622 fn test_toast_icon_chars() {
1623 assert_eq!(ToastIcon::Success.as_char(), '\u{2713}');
1624 assert_eq!(ToastIcon::Error.as_char(), '\u{2717}');
1625 assert_eq!(ToastIcon::Warning.as_char(), '!');
1626 assert_eq!(ToastIcon::Info.as_char(), 'i');
1627 assert_eq!(ToastIcon::Custom('*').as_char(), '*');
1628
1629 assert_eq!(ToastIcon::Success.as_ascii(), '+');
1631 assert_eq!(ToastIcon::Error.as_ascii(), 'x');
1632 }
1633
1634 #[test]
1635 fn test_toast_dimensions() {
1636 let toast = Toast::new("Short");
1637 let (w, h) = toast.calculate_dimensions();
1638 assert_eq!(w, 9);
1640 assert_eq!(h, 3); let toast_with_title = Toast::new("Message").title("Title");
1643 let (_w, h) = toast_with_title.calculate_dimensions();
1644 assert_eq!(h, 4); }
1646
1647 #[test]
1648 fn test_toast_dimensions_with_icon() {
1649 let toast = Toast::new("Message").icon(ToastIcon::Success);
1650 let (w, _h) = toast.calculate_dimensions();
1651 let mut buf = [0u8; 4];
1652 let icon = ToastIcon::Success.as_char().encode_utf8(&mut buf);
1653 let expected = display_width(icon) + 1 + display_width("Message") + 4;
1654 assert_eq!(w, expected as u16);
1655 }
1656
1657 #[test]
1658 fn test_toast_dimensions_max_width() {
1659 let toast = Toast::new("This is a very long message that exceeds max width").max_width(20);
1660 let (w, _h) = toast.calculate_dimensions();
1661 assert!(w <= 20);
1662 }
1663
1664 #[test]
1665 fn test_toast_render_basic() {
1666 let toast = Toast::new("Hello");
1667 let area = Rect::new(0, 0, 15, 5);
1668 let mut pool = GraphemePool::new();
1669 let mut frame = Frame::new(15, 5, &mut pool);
1670 toast.render(area, &mut frame);
1671
1672 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); assert!(frame.buffer.get(1, 1).is_some()); }
1676
1677 #[test]
1678 fn test_toast_render_with_icon() {
1679 let toast = Toast::new("OK").icon(ToastIcon::Success);
1680 let area = Rect::new(0, 0, 10, 5);
1681 let mut pool = GraphemePool::new();
1682 let mut frame = Frame::new(10, 5, &mut pool);
1683 toast.render(area, &mut frame);
1684
1685 let icon_cell = cell_at(&frame, 1, 1);
1687 let ok = if let Some(ch) = icon_cell.content.as_char() {
1688 ch == '\u{2713}'
1689 } else if let Some(id) = icon_cell.content.grapheme_id() {
1690 frame.pool.get(id) == Some("\u{2713}")
1691 } else {
1692 false
1693 };
1694 assert!(ok, "expected toast icon cell to contain ✓");
1695 }
1696
1697 #[test]
1698 fn test_toast_render_with_title() {
1699 let toast = Toast::new("Body").title("Head");
1700 let area = Rect::new(0, 0, 15, 6);
1701 let mut pool = GraphemePool::new();
1702 let mut frame = Frame::new(15, 6, &mut pool);
1703 toast.render(area, &mut frame);
1704
1705 let title_cell = cell_at(&frame, 1, 1);
1707 assert_eq!(title_cell.content.as_char(), Some('H'));
1708 }
1709
1710 #[test]
1711 fn test_toast_render_zero_area() {
1712 let toast = Toast::new("Test");
1713 let area = Rect::new(0, 0, 0, 0);
1714 let mut pool = GraphemePool::new();
1715 let mut frame = Frame::new(1, 1, &mut pool);
1716 toast.render(area, &mut frame); }
1718
1719 #[test]
1720 fn test_toast_render_small_area() {
1721 let toast = Toast::new("Test");
1722 let area = Rect::new(0, 0, 2, 2);
1723 let mut pool = GraphemePool::new();
1724 let mut frame = Frame::new(2, 2, &mut pool);
1725 toast.render(area, &mut frame); }
1727
1728 #[test]
1729 fn test_toast_not_visible_when_dismissed() {
1730 let mut toast = Toast::new("Test");
1731 toast.dismiss();
1732 let area = Rect::new(0, 0, 20, 5);
1733 let mut pool = GraphemePool::new();
1734 let mut frame = Frame::new(20, 5, &mut pool);
1735
1736 let original = cell_at(&frame, 0, 0).content.as_char();
1738
1739 toast.render(area, &mut frame);
1740
1741 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), original);
1743 }
1744
1745 #[test]
1746 fn test_toast_is_not_essential() {
1747 let toast = Toast::new("Test");
1748 assert!(!toast.is_essential());
1749 }
1750
1751 #[test]
1752 fn test_toast_id_uniqueness() {
1753 let toast1 = Toast::new("A");
1754 let toast2 = Toast::new("B");
1755 assert_ne!(toast1.id, toast2.id);
1756 }
1757
1758 #[test]
1759 fn test_toast_style_variants() {
1760 let success = Toast::new("OK").style_variant(ToastStyle::Success);
1761 let error = Toast::new("Fail").style_variant(ToastStyle::Error);
1762 let warning = Toast::new("Warn").style_variant(ToastStyle::Warning);
1763 let info = Toast::new("Info").style_variant(ToastStyle::Info);
1764 let neutral = Toast::new("Neutral").style_variant(ToastStyle::Neutral);
1765
1766 assert_eq!(success.config.style_variant, ToastStyle::Success);
1767 assert_eq!(error.config.style_variant, ToastStyle::Error);
1768 assert_eq!(warning.config.style_variant, ToastStyle::Warning);
1769 assert_eq!(info.config.style_variant, ToastStyle::Info);
1770 assert_eq!(neutral.config.style_variant, ToastStyle::Neutral);
1771 }
1772
1773 #[test]
1774 fn test_toast_content_builder() {
1775 let content = ToastContent::new("Message")
1776 .with_icon(ToastIcon::Warning)
1777 .with_title("Alert");
1778
1779 assert_eq!(content.message, "Message");
1780 assert_eq!(content.icon, Some(ToastIcon::Warning));
1781 assert_eq!(content.title, Some("Alert".to_string()));
1782 }
1783
1784 #[test]
1787 fn test_animation_phase_default() {
1788 let toast = Toast::new("Test");
1789 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
1790 }
1791
1792 #[test]
1793 fn test_animation_phase_reduced_motion() {
1794 let toast = Toast::new("Test").reduced_motion(true);
1795 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1796 assert!(toast.state.animation.reduced_motion);
1797 }
1798
1799 #[test]
1800 fn test_animation_no_animation() {
1801 let toast = Toast::new("Test").no_animation();
1802 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1803 assert!(toast.config.animation.is_disabled());
1804 }
1805
1806 #[test]
1807 fn test_entrance_animation_builder() {
1808 let toast = Toast::new("Test")
1809 .entrance_animation(ToastEntranceAnimation::SlideFromTop)
1810 .entrance_duration(Duration::from_millis(300))
1811 .entrance_easing(ToastEasing::Bounce);
1812
1813 assert_eq!(
1814 toast.config.animation.entrance,
1815 ToastEntranceAnimation::SlideFromTop
1816 );
1817 assert_eq!(
1818 toast.config.animation.entrance_duration,
1819 Duration::from_millis(300)
1820 );
1821 assert_eq!(toast.config.animation.entrance_easing, ToastEasing::Bounce);
1822 }
1823
1824 #[test]
1825 fn test_exit_animation_builder() {
1826 let toast = Toast::new("Test")
1827 .exit_animation(ToastExitAnimation::SlideOut)
1828 .exit_duration(Duration::from_millis(100))
1829 .exit_easing(ToastEasing::EaseInOut);
1830
1831 assert_eq!(toast.config.animation.exit, ToastExitAnimation::SlideOut);
1832 assert_eq!(
1833 toast.config.animation.exit_duration,
1834 Duration::from_millis(100)
1835 );
1836 assert_eq!(toast.config.animation.exit_easing, ToastEasing::EaseInOut);
1837 }
1838
1839 #[test]
1840 fn test_entrance_animation_offsets() {
1841 let width = 30u16;
1842 let height = 5u16;
1843
1844 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1846 assert_eq!(dx, 0);
1847 assert_eq!(dy, -(height as i16));
1848
1849 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(0.0, width, height);
1851 assert_eq!(dx, 0);
1852 assert_eq!(dy, -(height as i16));
1853
1854 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1856 assert_eq!(dx, 0);
1857 assert_eq!(dy, 0);
1858
1859 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.initial_offset(width, height);
1861 assert_eq!(dx, width as i16);
1862 assert_eq!(dy, 0);
1863 }
1864
1865 #[test]
1866 fn test_exit_animation_offsets() {
1867 let width = 30u16;
1868 let height = 5u16;
1869 let entrance = ToastEntranceAnimation::SlideFromRight;
1870
1871 let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1873 assert_eq!(dx, -(width as i16)); assert_eq!(dy, 0);
1875
1876 let (dx, dy) =
1878 ToastExitAnimation::SlideOut.offset_at_progress(0.0, width, height, entrance);
1879 assert_eq!(dx, 0);
1880 assert_eq!(dy, 0);
1881
1882 let (dx, dy) =
1884 ToastExitAnimation::SlideOut.offset_at_progress(1.0, width, height, entrance);
1885 assert_eq!(dx, -(width as i16));
1886 assert_eq!(dy, 0);
1887 }
1888
1889 #[test]
1890 fn test_easing_apply() {
1891 assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
1893
1894 assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
1896
1897 assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
1899
1900 for easing in [
1902 ToastEasing::Linear,
1903 ToastEasing::EaseIn,
1904 ToastEasing::EaseOut,
1905 ToastEasing::EaseInOut,
1906 ToastEasing::Bounce,
1907 ] {
1908 assert!((easing.apply(0.0) - 0.0).abs() < 0.001, "{:?} at 0", easing);
1909 assert!((easing.apply(1.0) - 1.0).abs() < 0.001, "{:?} at 1", easing);
1910 }
1911 }
1912
1913 #[test]
1914 fn test_animation_state_progress() {
1915 let state = ToastAnimationState::new();
1916 let progress = state.progress(Duration::from_millis(200));
1918 assert!(
1919 progress < 0.1,
1920 "Progress should be small immediately after creation"
1921 );
1922 }
1923
1924 #[test]
1925 fn test_animation_state_zero_duration() {
1926 let state = ToastAnimationState::new();
1927 let progress = state.progress(Duration::ZERO);
1929 assert_eq!(progress, 1.0);
1930 }
1931
1932 #[test]
1933 fn test_dismiss_starts_exit_animation() {
1934 let mut toast = Toast::new("Test").no_animation();
1935 toast.state.animation.phase = ToastAnimationPhase::Visible;
1937 toast.state.animation.reduced_motion = false;
1938
1939 toast.dismiss();
1940
1941 assert!(toast.state.dismissed);
1942 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Exiting);
1943 }
1944
1945 #[test]
1946 fn test_dismiss_immediately() {
1947 let mut toast = Toast::new("Test");
1948 toast.dismiss_immediately();
1949
1950 assert!(toast.state.dismissed);
1951 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Hidden);
1952 assert!(!toast.is_visible());
1953 }
1954
1955 #[test]
1956 fn test_is_animating() {
1957 let toast = Toast::new("Test");
1958 assert!(toast.is_animating()); let toast_visible = Toast::new("Test").no_animation();
1961 assert!(!toast_visible.is_animating()); }
1963
1964 #[test]
1965 fn test_animation_opacity_fade_in() {
1966 let config = ToastAnimationConfig {
1967 entrance: ToastEntranceAnimation::FadeIn,
1968 exit: ToastExitAnimation::FadeOut,
1969 entrance_duration: Duration::from_millis(200),
1970 exit_duration: Duration::from_millis(150),
1971 entrance_easing: ToastEasing::Linear,
1972 exit_easing: ToastEasing::Linear,
1973 respect_reduced_motion: false,
1974 };
1975
1976 let mut state = ToastAnimationState::new();
1978 let opacity = state.current_opacity(&config);
1979 assert!(opacity < 0.1, "Should be low opacity at start");
1980
1981 state.phase = ToastAnimationPhase::Visible;
1983 let opacity = state.current_opacity(&config);
1984 assert!((opacity - 1.0).abs() < 0.001);
1985 }
1986
1987 #[test]
1988 fn test_animation_config_default() {
1989 let config = ToastAnimationConfig::default();
1990
1991 assert_eq!(config.entrance, ToastEntranceAnimation::SlideFromRight);
1992 assert_eq!(config.exit, ToastExitAnimation::FadeOut);
1993 assert_eq!(config.entrance_duration, Duration::from_millis(200));
1994 assert_eq!(config.exit_duration, Duration::from_millis(150));
1995 assert!(config.respect_reduced_motion);
1996 }
1997
1998 #[test]
1999 fn test_animation_affects_position() {
2000 assert!(ToastEntranceAnimation::SlideFromTop.affects_position());
2001 assert!(ToastEntranceAnimation::SlideFromRight.affects_position());
2002 assert!(!ToastEntranceAnimation::FadeIn.affects_position());
2003 assert!(!ToastEntranceAnimation::None.affects_position());
2004
2005 assert!(ToastExitAnimation::SlideOut.affects_position());
2006 assert!(ToastExitAnimation::SlideToLeft.affects_position());
2007 assert!(!ToastExitAnimation::FadeOut.affects_position());
2008 assert!(!ToastExitAnimation::None.affects_position());
2009 }
2010
2011 #[test]
2012 fn test_toast_animation_offset() {
2013 let toast = Toast::new("Test").entrance_animation(ToastEntranceAnimation::SlideFromRight);
2014 let (dx, dy) = toast.animation_offset();
2015 assert!(dx > 0, "Should have positive x offset at start");
2017 assert_eq!(dy, 0);
2018 }
2019
2020 #[test]
2023 fn action_builder_single() {
2024 let toast = Toast::new("msg").action(ToastAction::new("Retry", "retry"));
2025 assert_eq!(toast.actions.len(), 1);
2026 assert_eq!(toast.actions[0].label, "Retry");
2027 assert_eq!(toast.actions[0].id, "retry");
2028 }
2029
2030 #[test]
2031 fn action_builder_multiple() {
2032 let toast = Toast::new("msg")
2033 .action(ToastAction::new("Ack", "ack"))
2034 .action(ToastAction::new("Snooze", "snooze"));
2035 assert_eq!(toast.actions.len(), 2);
2036 }
2037
2038 #[test]
2039 fn action_builder_vec() {
2040 let actions = vec![
2041 ToastAction::new("A", "a"),
2042 ToastAction::new("B", "b"),
2043 ToastAction::new("C", "c"),
2044 ];
2045 let toast = Toast::new("msg").actions(actions);
2046 assert_eq!(toast.actions.len(), 3);
2047 }
2048
2049 #[test]
2050 fn action_display_width() {
2051 let a = ToastAction::new("OK", "ok");
2052 assert_eq!(a.display_width(), 4);
2054 }
2055
2056 #[test]
2057 fn handle_key_esc_dismisses() {
2058 let mut toast = Toast::new("msg").no_animation();
2059 let result = toast.handle_key(KeyEvent::Esc);
2060 assert_eq!(result, ToastEvent::Dismissed);
2061 }
2062
2063 #[test]
2064 fn handle_key_esc_clears_focus_first() {
2065 let mut toast = Toast::new("msg")
2066 .action(ToastAction::new("A", "a"))
2067 .no_animation();
2068 toast.handle_key(KeyEvent::Tab);
2070 assert!(toast.has_focus());
2071 let result = toast.handle_key(KeyEvent::Esc);
2073 assert_eq!(result, ToastEvent::None);
2074 assert!(!toast.has_focus());
2075 }
2076
2077 #[test]
2078 fn handle_key_tab_cycles_focus() {
2079 let mut toast = Toast::new("msg")
2080 .action(ToastAction::new("A", "a"))
2081 .action(ToastAction::new("B", "b"))
2082 .no_animation();
2083
2084 let r1 = toast.handle_key(KeyEvent::Tab);
2085 assert_eq!(r1, ToastEvent::FocusChanged);
2086 assert_eq!(toast.state.focused_action, Some(0));
2087
2088 let r2 = toast.handle_key(KeyEvent::Tab);
2089 assert_eq!(r2, ToastEvent::FocusChanged);
2090 assert_eq!(toast.state.focused_action, Some(1));
2091
2092 let r3 = toast.handle_key(KeyEvent::Tab);
2094 assert_eq!(r3, ToastEvent::FocusChanged);
2095 assert_eq!(toast.state.focused_action, Some(0));
2096 }
2097
2098 #[test]
2099 fn handle_key_tab_no_actions_is_noop() {
2100 let mut toast = Toast::new("msg").no_animation();
2101 let result = toast.handle_key(KeyEvent::Tab);
2102 assert_eq!(result, ToastEvent::None);
2103 }
2104
2105 #[test]
2106 fn handle_key_enter_invokes_action() {
2107 let mut toast = Toast::new("msg")
2108 .action(ToastAction::new("Retry", "retry"))
2109 .no_animation();
2110 toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2112 assert_eq!(result, ToastEvent::Action("retry".into()));
2113 }
2114
2115 #[test]
2116 fn handle_key_enter_no_focus_is_noop() {
2117 let mut toast = Toast::new("msg")
2118 .action(ToastAction::new("A", "a"))
2119 .no_animation();
2120 let result = toast.handle_key(KeyEvent::Enter);
2121 assert_eq!(result, ToastEvent::None);
2122 }
2123
2124 #[test]
2125 fn handle_key_other_is_noop() {
2126 let mut toast = Toast::new("msg").no_animation();
2127 let result = toast.handle_key(KeyEvent::Other);
2128 assert_eq!(result, ToastEvent::None);
2129 }
2130
2131 #[test]
2132 fn handle_key_dismissed_toast_is_noop() {
2133 let mut toast = Toast::new("msg").no_animation();
2134 toast.state.dismissed = true;
2135 let result = toast.handle_key(KeyEvent::Esc);
2136 assert_eq!(result, ToastEvent::None);
2137 }
2138
2139 #[test]
2140 fn pause_timer_sets_flag() {
2141 let mut toast = Toast::new("msg").no_animation();
2142 toast.pause_timer();
2143 assert!(toast.state.timer_paused);
2144 assert!(toast.state.pause_started.is_some());
2145 }
2146
2147 #[test]
2148 fn resume_timer_accumulates_paused() {
2149 let mut toast = Toast::new("msg").no_animation();
2150 toast.pause_timer();
2151 std::thread::sleep(Duration::from_millis(10));
2152 toast.resume_timer();
2153 assert!(!toast.state.timer_paused);
2154 assert!(toast.state.total_paused >= Duration::from_millis(5));
2155 }
2156
2157 #[test]
2158 fn pause_resume_idempotent() {
2159 let mut toast = Toast::new("msg").no_animation();
2160 toast.pause_timer();
2162 toast.pause_timer();
2163 assert!(toast.state.timer_paused);
2164 toast.resume_timer();
2166 toast.resume_timer();
2167 assert!(!toast.state.timer_paused);
2168 }
2169
2170 #[test]
2171 fn clear_focus_resumes_timer() {
2172 let mut toast = Toast::new("msg")
2173 .action(ToastAction::new("A", "a"))
2174 .no_animation();
2175 toast.handle_key(KeyEvent::Tab);
2176 assert!(toast.state.timer_paused);
2177 toast.clear_focus();
2178 assert!(!toast.has_focus());
2179 assert!(!toast.state.timer_paused);
2180 }
2181
2182 #[test]
2183 fn focused_action_returns_correct() {
2184 let mut toast = Toast::new("msg")
2185 .action(ToastAction::new("X", "x"))
2186 .action(ToastAction::new("Y", "y"))
2187 .no_animation();
2188 assert!(toast.focused_action().is_none());
2189 toast.handle_key(KeyEvent::Tab);
2190 assert_eq!(focused_action_id(&toast), "x");
2191 toast.handle_key(KeyEvent::Tab);
2192 assert_eq!(focused_action_id(&toast), "y");
2193 }
2194
2195 #[test]
2196 fn is_expired_accounts_for_pause() {
2197 let mut toast = Toast::new("msg")
2198 .duration(Duration::from_millis(50))
2199 .no_animation();
2200 toast.pause_timer();
2201 std::thread::sleep(Duration::from_millis(60));
2203 assert!(
2204 !toast.is_expired(),
2205 "Should not expire while timer is paused"
2206 );
2207 toast.resume_timer();
2208 assert!(
2210 !toast.is_expired(),
2211 "Should not expire immediately after resume because paused time was subtracted"
2212 );
2213 }
2214
2215 #[test]
2216 fn dimensions_include_actions_row() {
2217 let toast = Toast::new("Hi")
2218 .action(ToastAction::new("OK", "ok"))
2219 .no_animation();
2220 let (_, h) = toast.calculate_dimensions();
2221 assert_eq!(h, 4);
2224 }
2225
2226 #[test]
2227 fn dimensions_with_title_and_actions() {
2228 let toast = Toast::new("Hi")
2229 .title("Title")
2230 .action(ToastAction::new("OK", "ok"))
2231 .no_animation();
2232 let (_, h) = toast.calculate_dimensions();
2233 assert_eq!(h, 5);
2235 }
2236
2237 #[test]
2238 fn dimensions_width_accounts_for_actions() {
2239 let toast = Toast::new("Hi")
2240 .action(ToastAction::new("LongButtonLabel", "lb"))
2241 .no_animation();
2242 let (w, _) = toast.calculate_dimensions();
2243 assert!(w >= 20);
2246 }
2247
2248 #[test]
2249 fn render_with_actions_does_not_panic() {
2250 let toast = Toast::new("Test")
2251 .action(ToastAction::new("OK", "ok"))
2252 .action(ToastAction::new("Cancel", "cancel"))
2253 .no_animation();
2254
2255 let mut pool = GraphemePool::new();
2256 let mut frame = Frame::new(60, 20, &mut pool);
2257 let area = Rect::new(0, 0, 40, 10);
2258 toast.render(area, &mut frame);
2259 }
2260
2261 #[test]
2262 fn render_focused_action_does_not_panic() {
2263 let mut toast = Toast::new("Test")
2264 .action(ToastAction::new("OK", "ok"))
2265 .no_animation();
2266 toast.handle_key(KeyEvent::Tab); let mut pool = GraphemePool::new();
2269 let mut frame = Frame::new(60, 20, &mut pool);
2270 let area = Rect::new(0, 0, 40, 10);
2271 toast.render(area, &mut frame);
2272 }
2273
2274 #[test]
2275 fn render_actions_tiny_area_does_not_panic() {
2276 let toast = Toast::new("X")
2277 .action(ToastAction::new("A", "a"))
2278 .no_animation();
2279
2280 let mut pool = GraphemePool::new();
2281 let mut frame = Frame::new(5, 3, &mut pool);
2282 let area = Rect::new(0, 0, 5, 3);
2283 toast.render(area, &mut frame);
2284 }
2285
2286 #[test]
2287 fn toast_action_styles() {
2288 let style = Style::new().bold();
2289 let focus_style = Style::new().italic();
2290 let toast = Toast::new("msg")
2291 .action(ToastAction::new("A", "a"))
2292 .with_action_style(style)
2293 .with_action_focus_style(focus_style);
2294 assert_eq!(toast.action_style, style);
2295 assert_eq!(toast.action_focus_style, focus_style);
2296 }
2297
2298 #[test]
2299 fn persistent_toast_not_expired_with_actions() {
2300 let toast = Toast::new("msg")
2301 .persistent()
2302 .action(ToastAction::new("Dismiss", "dismiss"))
2303 .no_animation();
2304 std::thread::sleep(Duration::from_millis(10));
2305 assert!(!toast.is_expired());
2306 }
2307
2308 #[test]
2309 fn action_invoke_second_button() {
2310 let mut toast = Toast::new("msg")
2311 .action(ToastAction::new("A", "a"))
2312 .action(ToastAction::new("B", "b"))
2313 .no_animation();
2314 toast.handle_key(KeyEvent::Tab); toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2317 assert_eq!(result, ToastEvent::Action("b".into()));
2318 }
2319
2320 #[test]
2321 fn remaining_time_with_pause() {
2322 let toast = Toast::new("msg")
2323 .duration(Duration::from_secs(10))
2324 .no_animation();
2325 let remaining = toast.remaining_time();
2326 assert!(remaining.is_some());
2327 let r = unwrap_remaining(remaining);
2328 assert!(r > Duration::from_secs(9));
2329 }
2330
2331 #[test]
2336 fn position_bottom_left() {
2337 let (x, y) = ToastPosition::BottomLeft.calculate_position(80, 24, 20, 3, 1);
2338 assert_eq!(x, 1);
2339 assert_eq!(y, 24 - 3 - 1); }
2341
2342 #[test]
2343 fn position_bottom_center() {
2344 let (x, y) = ToastPosition::BottomCenter.calculate_position(80, 24, 20, 3, 1);
2345 assert_eq!(x, (80 - 20) / 2); assert_eq!(y, 24 - 3 - 1); }
2348
2349 #[test]
2350 fn position_toast_wider_than_terminal_saturates() {
2351 let (x, y) = ToastPosition::TopRight.calculate_position(20, 10, 30, 3, 1);
2353 assert_eq!(x, 0); assert_eq!(y, 1);
2355 }
2356
2357 #[test]
2358 fn position_zero_margin() {
2359 let (x, y) = ToastPosition::TopLeft.calculate_position(80, 24, 20, 3, 0);
2360 assert_eq!(x, 0);
2361 assert_eq!(y, 0);
2362
2363 let (x, y) = ToastPosition::BottomRight.calculate_position(80, 24, 20, 3, 0);
2364 assert_eq!(x, 60);
2365 assert_eq!(y, 21);
2366 }
2367
2368 #[test]
2369 fn position_toast_taller_than_terminal_saturates() {
2370 let (_, y) = ToastPosition::BottomLeft.calculate_position(80, 3, 20, 10, 1);
2371 assert_eq!(y, 0); }
2373
2374 #[test]
2379 fn icon_custom_non_ascii_falls_back_to_star() {
2380 let icon = ToastIcon::Custom('\u{1F525}'); assert_eq!(icon.as_char(), '\u{1F525}');
2382 assert_eq!(icon.as_ascii(), '*');
2383 }
2384
2385 #[test]
2386 fn icon_custom_ascii_preserved() {
2387 let icon = ToastIcon::Custom('#');
2388 assert_eq!(icon.as_char(), '#');
2389 assert_eq!(icon.as_ascii(), '#');
2390 }
2391
2392 #[test]
2393 fn icon_warning_ascii_same() {
2394 assert_eq!(ToastIcon::Warning.as_ascii(), '!');
2395 assert_eq!(ToastIcon::Info.as_ascii(), 'i');
2396 }
2397
2398 #[test]
2403 fn toast_position_default_is_top_right() {
2404 assert_eq!(ToastPosition::default(), ToastPosition::TopRight);
2405 }
2406
2407 #[test]
2408 fn toast_icon_default_is_info() {
2409 assert_eq!(ToastIcon::default(), ToastIcon::Info);
2410 }
2411
2412 #[test]
2413 fn toast_style_default_is_info() {
2414 assert_eq!(ToastStyle::default(), ToastStyle::Info);
2415 }
2416
2417 #[test]
2418 fn toast_animation_phase_default_is_visible() {
2419 assert_eq!(ToastAnimationPhase::default(), ToastAnimationPhase::Visible);
2420 }
2421
2422 #[test]
2423 fn toast_entrance_animation_default_is_slide_from_right() {
2424 assert_eq!(
2425 ToastEntranceAnimation::default(),
2426 ToastEntranceAnimation::SlideFromRight
2427 );
2428 }
2429
2430 #[test]
2431 fn toast_exit_animation_default_is_fade_out() {
2432 assert_eq!(ToastExitAnimation::default(), ToastExitAnimation::FadeOut);
2433 }
2434
2435 #[test]
2436 fn toast_easing_default_is_ease_out() {
2437 assert_eq!(ToastEasing::default(), ToastEasing::EaseOut);
2438 }
2439
2440 #[test]
2445 fn entrance_slide_from_bottom_offset() {
2446 let (dx, dy) = ToastEntranceAnimation::SlideFromBottom.initial_offset(20, 5);
2447 assert_eq!(dx, 0);
2448 assert_eq!(dy, 5); }
2450
2451 #[test]
2452 fn entrance_slide_from_left_offset() {
2453 let (dx, dy) = ToastEntranceAnimation::SlideFromLeft.initial_offset(20, 5);
2454 assert_eq!(dx, -20);
2455 assert_eq!(dy, 0);
2456 }
2457
2458 #[test]
2459 fn entrance_fade_in_no_offset() {
2460 let (dx, dy) = ToastEntranceAnimation::FadeIn.initial_offset(20, 5);
2461 assert_eq!(dx, 0);
2462 assert_eq!(dy, 0);
2463 }
2464
2465 #[test]
2466 fn entrance_none_no_offset() {
2467 let (dx, dy) = ToastEntranceAnimation::None.initial_offset(20, 5);
2468 assert_eq!(dx, 0);
2469 assert_eq!(dy, 0);
2470 }
2471
2472 #[test]
2473 fn entrance_offset_progress_clamped() {
2474 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(-0.5, 20, 5);
2476 assert_eq!(dx, 0);
2477 assert_eq!(dy, -5); let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(2.0, 20, 5);
2481 assert_eq!(dx, 0);
2482 assert_eq!(dy, 0); }
2484
2485 #[test]
2486 fn entrance_offset_at_half_progress() {
2487 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.offset_at_progress(0.5, 20, 5);
2488 assert_eq!(dx, 10); assert_eq!(dy, 0);
2490 }
2491
2492 #[test]
2497 fn exit_slide_to_top_offset() {
2498 let entrance = ToastEntranceAnimation::SlideFromRight;
2499 let (dx, dy) = ToastExitAnimation::SlideToTop.final_offset(20, 5, entrance);
2500 assert_eq!(dx, 0);
2501 assert_eq!(dy, -5);
2502 }
2503
2504 #[test]
2505 fn exit_slide_to_right_offset() {
2506 let entrance = ToastEntranceAnimation::SlideFromRight;
2507 let (dx, dy) = ToastExitAnimation::SlideToRight.final_offset(20, 5, entrance);
2508 assert_eq!(dx, 20);
2509 assert_eq!(dy, 0);
2510 }
2511
2512 #[test]
2513 fn exit_slide_to_bottom_offset() {
2514 let entrance = ToastEntranceAnimation::SlideFromRight;
2515 let (dx, dy) = ToastExitAnimation::SlideToBottom.final_offset(20, 5, entrance);
2516 assert_eq!(dx, 0);
2517 assert_eq!(dy, 5);
2518 }
2519
2520 #[test]
2521 fn exit_slide_to_left_offset() {
2522 let entrance = ToastEntranceAnimation::SlideFromRight;
2523 let (dx, dy) = ToastExitAnimation::SlideToLeft.final_offset(20, 5, entrance);
2524 assert_eq!(dx, -20);
2525 assert_eq!(dy, 0);
2526 }
2527
2528 #[test]
2529 fn exit_fade_out_no_offset() {
2530 let entrance = ToastEntranceAnimation::SlideFromRight;
2531 let (dx, dy) = ToastExitAnimation::FadeOut.final_offset(20, 5, entrance);
2532 assert_eq!(dx, 0);
2533 assert_eq!(dy, 0);
2534 }
2535
2536 #[test]
2537 fn exit_none_no_offset() {
2538 let entrance = ToastEntranceAnimation::SlideFromRight;
2539 let (dx, dy) = ToastExitAnimation::None.final_offset(20, 5, entrance);
2540 assert_eq!(dx, 0);
2541 assert_eq!(dy, 0);
2542 }
2543
2544 #[test]
2545 fn exit_offset_progress_clamped() {
2546 let entrance = ToastEntranceAnimation::SlideFromRight;
2547 let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(-1.0, 20, 5, entrance);
2548 assert_eq!((dx, dy), (0, 0)); let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(5.0, 20, 5, entrance);
2551 assert_eq!((dx, dy), (0, -5)); }
2553
2554 #[test]
2559 fn easing_clamped_below_zero() {
2560 for easing in [
2561 ToastEasing::Linear,
2562 ToastEasing::EaseIn,
2563 ToastEasing::EaseOut,
2564 ToastEasing::EaseInOut,
2565 ToastEasing::Bounce,
2566 ] {
2567 let result = easing.apply(-0.5);
2568 assert!(
2569 (result - 0.0).abs() < 0.001,
2570 "{easing:?} at -0.5 should clamp to 0"
2571 );
2572 }
2573 }
2574
2575 #[test]
2576 fn easing_clamped_above_one() {
2577 for easing in [
2578 ToastEasing::Linear,
2579 ToastEasing::EaseIn,
2580 ToastEasing::EaseOut,
2581 ToastEasing::EaseInOut,
2582 ToastEasing::Bounce,
2583 ] {
2584 let result = easing.apply(1.5);
2585 assert!(
2586 (result - 1.0).abs() < 0.001,
2587 "{easing:?} at 1.5 should clamp to 1"
2588 );
2589 }
2590 }
2591
2592 #[test]
2593 fn easing_ease_in_out_first_half() {
2594 let result = ToastEasing::EaseInOut.apply(0.25);
2595 assert!(
2596 result < 0.25,
2597 "EaseInOut at 0.25 should be < 0.25 (accelerating)"
2598 );
2599 }
2600
2601 #[test]
2602 fn easing_ease_in_out_second_half() {
2603 let result = ToastEasing::EaseInOut.apply(0.75);
2604 assert!(
2605 result > 0.75,
2606 "EaseInOut at 0.75 should be > 0.75 (decelerating)"
2607 );
2608 }
2609
2610 #[test]
2611 fn easing_bounce_monotonic_at_key_points() {
2612 let d1 = 2.75;
2613 let t1 = 0.2 / d1; let t2 = 1.5 / d1; let t3 = 2.3 / d1; let t4 = 2.7 / d1; let v1 = ToastEasing::Bounce.apply(t1);
2620 let v2 = ToastEasing::Bounce.apply(t2);
2621 let v3 = ToastEasing::Bounce.apply(t3);
2622 let v4 = ToastEasing::Bounce.apply(t4);
2623
2624 assert!((0.0..=1.0).contains(&v1), "branch 1: {v1}");
2625 assert!((0.0..=1.0).contains(&v2), "branch 2: {v2}");
2626 assert!((0.0..=1.0).contains(&v3), "branch 3: {v3}");
2627 assert!((0.0..=1.0).contains(&v4), "branch 4: {v4}");
2628 }
2629
2630 #[test]
2635 fn animation_state_tick_entering_to_visible() {
2636 let config = ToastAnimationConfig {
2637 entrance_duration: Duration::ZERO, ..ToastAnimationConfig::default()
2639 };
2640 let mut state = ToastAnimationState::new();
2641 assert_eq!(state.phase, ToastAnimationPhase::Entering);
2642
2643 let changed = state.tick(&config);
2644 assert!(changed, "Phase should change from Entering to Visible");
2645 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2646 }
2647
2648 #[test]
2649 fn animation_state_tick_exiting_to_hidden() {
2650 let config = ToastAnimationConfig {
2651 exit_duration: Duration::ZERO,
2652 ..ToastAnimationConfig::default()
2653 };
2654 let mut state = ToastAnimationState::new();
2655 state.transition_to(ToastAnimationPhase::Exiting);
2656
2657 let changed = state.tick(&config);
2658 assert!(changed, "Phase should change from Exiting to Hidden");
2659 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2660 }
2661
2662 #[test]
2663 fn animation_state_tick_visible_no_change() {
2664 let config = ToastAnimationConfig::default();
2665 let mut state = ToastAnimationState::new();
2666 state.transition_to(ToastAnimationPhase::Visible);
2667
2668 let changed = state.tick(&config);
2669 assert!(!changed, "Visible phase should not auto-transition");
2670 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2671 }
2672
2673 #[test]
2674 fn animation_state_tick_hidden_no_change() {
2675 let config = ToastAnimationConfig::default();
2676 let mut state = ToastAnimationState::new();
2677 state.transition_to(ToastAnimationPhase::Hidden);
2678
2679 let changed = state.tick(&config);
2680 assert!(!changed);
2681 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2682 }
2683
2684 #[test]
2685 fn animation_state_start_exit_reduced_motion_goes_to_hidden() {
2686 let mut state = ToastAnimationState::with_reduced_motion();
2687 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2688 state.start_exit();
2689 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2690 }
2691
2692 #[test]
2693 fn animation_state_is_complete() {
2694 let mut state = ToastAnimationState::new();
2695 assert!(!state.is_complete());
2696 state.transition_to(ToastAnimationPhase::Hidden);
2697 assert!(state.is_complete());
2698 }
2699
2700 #[test]
2705 fn animation_offset_visible_is_zero() {
2706 let config = ToastAnimationConfig::default();
2707 let mut state = ToastAnimationState::new();
2708 state.phase = ToastAnimationPhase::Visible;
2709 let (dx, dy) = state.current_offset(&config, 20, 5);
2710 assert_eq!((dx, dy), (0, 0));
2711 }
2712
2713 #[test]
2714 fn animation_offset_hidden_is_zero() {
2715 let config = ToastAnimationConfig::default();
2716 let mut state = ToastAnimationState::new();
2717 state.phase = ToastAnimationPhase::Hidden;
2718 let (dx, dy) = state.current_offset(&config, 20, 5);
2719 assert_eq!((dx, dy), (0, 0));
2720 }
2721
2722 #[test]
2723 fn animation_offset_reduced_motion_always_zero() {
2724 let config = ToastAnimationConfig::default();
2725 let state = ToastAnimationState::with_reduced_motion();
2726 let (dx, dy) = state.current_offset(&config, 20, 5);
2727 assert_eq!((dx, dy), (0, 0));
2728 }
2729
2730 #[test]
2731 fn animation_opacity_visible_is_one() {
2732 let config = ToastAnimationConfig::default();
2733 let mut state = ToastAnimationState::new();
2734 state.phase = ToastAnimationPhase::Visible;
2735 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2736 }
2737
2738 #[test]
2739 fn animation_opacity_hidden_is_zero() {
2740 let config = ToastAnimationConfig::default();
2741 let mut state = ToastAnimationState::new();
2742 state.phase = ToastAnimationPhase::Hidden;
2743 assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2744 }
2745
2746 #[test]
2747 fn animation_opacity_reduced_motion_visible_is_one() {
2748 let config = ToastAnimationConfig::default();
2749 let mut state = ToastAnimationState::with_reduced_motion();
2750 state.phase = ToastAnimationPhase::Visible;
2751 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2752 }
2753
2754 #[test]
2755 fn animation_opacity_reduced_motion_hidden_is_zero() {
2756 let config = ToastAnimationConfig::default();
2757 let mut state = ToastAnimationState::with_reduced_motion();
2758 state.phase = ToastAnimationPhase::Hidden;
2759 assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2760 }
2761
2762 #[test]
2763 fn animation_opacity_exiting_non_fade_is_one() {
2764 let config = ToastAnimationConfig {
2765 exit: ToastExitAnimation::SlideOut,
2766 ..ToastAnimationConfig::default()
2767 };
2768 let mut state = ToastAnimationState::new();
2769 state.phase = ToastAnimationPhase::Exiting;
2770 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2772 }
2773
2774 #[test]
2775 fn animation_opacity_entering_non_fade_is_one() {
2776 let config = ToastAnimationConfig {
2777 entrance: ToastEntranceAnimation::SlideFromTop,
2778 ..ToastAnimationConfig::default()
2779 };
2780 let mut state = ToastAnimationState::new();
2781 state.phase = ToastAnimationPhase::Entering;
2782 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2784 }
2785
2786 #[test]
2791 fn toast_with_id() {
2792 let toast = Toast::with_id(ToastId::new(42), "Custom ID");
2793 assert_eq!(toast.id, ToastId::new(42));
2794 assert_eq!(toast.content.message, "Custom ID");
2795 }
2796
2797 #[test]
2798 fn toast_tick_animation_returns_true_on_phase_change() {
2799 let mut toast = Toast::new("Test").entrance_duration(Duration::ZERO);
2800 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
2801 let changed = toast.tick_animation();
2802 assert!(changed);
2803 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2804 }
2805
2806 #[test]
2807 fn toast_tick_animation_returns_false_when_stable() {
2808 let mut toast = Toast::new("Test").no_animation();
2809 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2810 let changed = toast.tick_animation();
2811 assert!(!changed);
2812 }
2813
2814 #[test]
2815 fn toast_animation_phase_accessor() {
2816 let toast = Toast::new("Test").no_animation();
2817 assert_eq!(toast.animation_phase(), ToastAnimationPhase::Visible);
2818 }
2819
2820 #[test]
2821 fn toast_animation_opacity_accessor() {
2822 let toast = Toast::new("Test").no_animation();
2823 assert!((toast.animation_opacity() - 1.0).abs() < 0.001);
2824 }
2825
2826 #[test]
2827 fn toast_remaining_time_persistent_is_none() {
2828 let toast = Toast::new("msg").persistent().no_animation();
2829 assert!(toast.remaining_time().is_none());
2830 }
2831
2832 #[test]
2833 fn toast_dismiss_twice_idempotent() {
2834 let mut toast = Toast::new("msg").no_animation();
2835 toast.state.animation.reduced_motion = false;
2836 toast.dismiss();
2837 assert!(toast.state.dismissed);
2838 let phase_after_first = toast.state.animation.phase;
2839 toast.dismiss(); assert_eq!(toast.state.animation.phase, phase_after_first);
2841 }
2842
2843 #[test]
2844 fn toast_non_dismissable_esc_noop() {
2845 let mut toast = Toast::new("msg").dismissable(false).no_animation();
2846 let result = toast.handle_key(KeyEvent::Esc);
2847 assert_eq!(result, ToastEvent::None);
2848 assert!(toast.is_visible());
2849 }
2850
2851 #[test]
2852 fn toast_margin_builder() {
2853 let toast = Toast::new("msg").margin(5);
2854 assert_eq!(toast.config.margin, 5);
2855 }
2856
2857 #[test]
2858 fn toast_with_icon_style_builder() {
2859 let style = Style::new().italic();
2860 let toast = Toast::new("msg").with_icon_style(style);
2861 assert_eq!(toast.icon_style, style);
2862 }
2863
2864 #[test]
2865 fn toast_with_title_style_builder() {
2866 let style = Style::new().bold();
2867 let toast = Toast::new("msg").with_title_style(style);
2868 assert_eq!(toast.title_style, style);
2869 }
2870
2871 #[test]
2876 fn toast_config_default_values() {
2877 let config = ToastConfig::default();
2878 assert_eq!(config.position, ToastPosition::TopRight);
2879 assert_eq!(config.duration, Some(Duration::from_secs(5)));
2880 assert_eq!(config.style_variant, ToastStyle::Info);
2881 assert_eq!(config.max_width, 50);
2882 assert_eq!(config.margin, 1);
2883 assert!(config.dismissable);
2884 }
2885
2886 #[test]
2891 fn animation_config_none_fields() {
2892 let config = ToastAnimationConfig::none();
2893 assert_eq!(config.entrance, ToastEntranceAnimation::None);
2894 assert_eq!(config.exit, ToastExitAnimation::None);
2895 assert_eq!(config.entrance_duration, Duration::ZERO);
2896 assert_eq!(config.exit_duration, Duration::ZERO);
2897 assert!(config.is_disabled());
2898 }
2899
2900 #[test]
2901 fn animation_config_is_disabled_false_for_default() {
2902 let config = ToastAnimationConfig::default();
2903 assert!(!config.is_disabled());
2904 }
2905
2906 #[test]
2911 fn toast_id_hash_consistent() {
2912 use std::collections::HashSet;
2913 let mut set = HashSet::new();
2914 set.insert(ToastId::new(1));
2915 set.insert(ToastId::new(2));
2916 set.insert(ToastId::new(1)); assert_eq!(set.len(), 2);
2918 }
2919
2920 #[test]
2921 fn toast_id_debug() {
2922 let id = ToastId::new(42);
2923 let dbg = format!("{:?}", id);
2924 assert!(dbg.contains("42"), "Debug: {dbg}");
2925 }
2926
2927 #[test]
2928 fn toast_event_debug_clone() {
2929 let event = ToastEvent::Action("test".into());
2930 let dbg = format!("{:?}", event);
2931 assert!(dbg.contains("Action"), "Debug: {dbg}");
2932 let cloned = event.clone();
2933 assert_eq!(cloned, ToastEvent::Action("test".into()));
2934 }
2935
2936 #[test]
2937 fn key_event_traits() {
2938 let key = KeyEvent::Tab;
2939 let copy = key; assert_eq!(key, copy);
2941 let dbg = format!("{:?}", key);
2942 assert!(dbg.contains("Tab"), "Debug: {dbg}");
2943 }
2944
2945 #[test]
2950 fn animation_tick_entering_reduced_motion_transitions_immediately() {
2951 let config = ToastAnimationConfig::default();
2952 let mut state = ToastAnimationState {
2953 phase: ToastAnimationPhase::Entering,
2954 phase_started: Instant::now(),
2955 reduced_motion: true,
2956 };
2957 let changed = state.tick(&config);
2959 assert!(changed);
2960 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2961 }
2962
2963 #[test]
2964 fn animation_tick_exiting_reduced_motion_transitions_immediately() {
2965 let config = ToastAnimationConfig::default();
2966 let mut state = ToastAnimationState {
2967 phase: ToastAnimationPhase::Exiting,
2968 phase_started: Instant::now(),
2969 reduced_motion: true,
2970 };
2971 let changed = state.tick(&config);
2972 assert!(changed);
2973 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2974 }
2975}