1#![forbid(unsafe_code)]
2
3use web_time::{Duration, Instant};
23
24use crate::{Widget, clear_text_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 duration_explicit: bool,
583 pub style_variant: ToastStyle,
585 pub max_width: u16,
587 pub margin: u16,
589 pub dismissable: bool,
591 pub animation: ToastAnimationConfig,
593}
594
595impl Default for ToastConfig {
596 fn default() -> Self {
597 Self {
598 position: ToastPosition::default(),
599 duration: Some(Duration::from_secs(5)),
600 duration_explicit: false,
601 style_variant: ToastStyle::default(),
602 max_width: 50,
603 margin: 1,
604 dismissable: true,
605 animation: ToastAnimationConfig::default(),
606 }
607 }
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub enum KeyEvent {
617 Esc,
619 Tab,
621 Enter,
623 Other,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct ToastAction {
646 pub label: String,
648 pub id: String,
650}
651
652impl ToastAction {
653 pub fn new(label: impl Into<String>, id: impl Into<String>) -> Self {
659 let label = label.into();
660 let id = id.into();
661 debug_assert!(
662 !label.trim().is_empty(),
663 "ToastAction label must not be empty"
664 );
665 debug_assert!(!id.trim().is_empty(), "ToastAction id must not be empty");
666 Self { label, id }
667 }
668
669 pub fn display_width(&self) -> usize {
673 display_width(self.label.as_str()) + 2 }
675}
676
677#[derive(Debug, Clone, PartialEq, Eq)]
681pub enum ToastEvent {
682 None,
684 Dismissed,
686 Action(String),
688 FocusChanged,
690}
691
692#[derive(Debug, Clone)]
694pub struct ToastContent {
695 pub message: String,
697 pub icon: Option<ToastIcon>,
699 pub title: Option<String>,
701}
702
703impl ToastContent {
704 pub fn new(message: impl Into<String>) -> Self {
706 Self {
707 message: message.into(),
708 icon: None,
709 title: None,
710 }
711 }
712
713 #[must_use]
715 pub fn with_icon(mut self, icon: ToastIcon) -> Self {
716 self.icon = Some(icon);
717 self
718 }
719
720 #[must_use]
722 pub fn with_title(mut self, title: impl Into<String>) -> Self {
723 self.title = Some(title.into());
724 self
725 }
726}
727
728#[derive(Debug, Clone)]
730pub struct ToastState {
731 pub created_at: Instant,
733 pub dismissed: bool,
735 pub animation: ToastAnimationState,
737 pub focused_action: Option<usize>,
739 pub timer_paused: bool,
741 pub pause_started: Option<Instant>,
743 pub total_paused: Duration,
745}
746
747impl Default for ToastState {
748 fn default() -> Self {
749 Self {
750 created_at: Instant::now(),
751 dismissed: false,
752 animation: ToastAnimationState::default(),
753 focused_action: None,
754 timer_paused: false,
755 pause_started: None,
756 total_paused: Duration::ZERO,
757 }
758 }
759}
760
761impl ToastState {
762 pub fn with_reduced_motion() -> Self {
764 Self {
765 created_at: Instant::now(),
766 dismissed: false,
767 animation: ToastAnimationState::with_reduced_motion(),
768 focused_action: None,
769 timer_paused: false,
770 pause_started: None,
771 total_paused: Duration::ZERO,
772 }
773 }
774}
775
776#[derive(Debug, Clone)]
794pub struct Toast {
795 pub id: ToastId,
797 pub content: ToastContent,
799 pub config: ToastConfig,
801 pub state: ToastState,
803 pub actions: Vec<ToastAction>,
805 style: Style,
807 icon_style: Style,
809 title_style: Style,
811 action_style: Style,
813 action_focus_style: Style,
815}
816
817impl Toast {
818 pub fn new(message: impl Into<String>) -> Self {
820 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
821 let id = ToastId::new(NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
822
823 Self {
824 id,
825 content: ToastContent::new(message),
826 config: ToastConfig::default(),
827 state: ToastState::default(),
828 actions: Vec::new(),
829 style: Style::default(),
830 icon_style: Style::default(),
831 title_style: Style::default(),
832 action_style: Style::default(),
833 action_focus_style: Style::default(),
834 }
835 }
836
837 pub fn with_id(id: ToastId, message: impl Into<String>) -> Self {
839 Self {
840 id,
841 content: ToastContent::new(message),
842 config: ToastConfig::default(),
843 state: ToastState::default(),
844 actions: Vec::new(),
845 style: Style::default(),
846 icon_style: Style::default(),
847 title_style: Style::default(),
848 action_style: Style::default(),
849 action_focus_style: Style::default(),
850 }
851 }
852
853 #[must_use]
857 pub fn icon(mut self, icon: ToastIcon) -> Self {
858 self.content.icon = Some(icon);
859 self
860 }
861
862 #[must_use]
864 pub fn title(mut self, title: impl Into<String>) -> Self {
865 self.content.title = Some(title.into());
866 self
867 }
868
869 #[must_use]
871 pub fn position(mut self, position: ToastPosition) -> Self {
872 self.config.position = position;
873 self
874 }
875
876 #[must_use]
878 pub fn duration(mut self, duration: Duration) -> Self {
879 self.config.duration = Some(duration);
880 self.config.duration_explicit = true;
881 self
882 }
883
884 #[must_use]
886 pub fn persistent(mut self) -> Self {
887 self.config.duration = None;
888 self.config.duration_explicit = true;
889 self
890 }
891
892 #[must_use]
894 pub fn style_variant(mut self, variant: ToastStyle) -> Self {
895 self.config.style_variant = variant;
896 self
897 }
898
899 #[must_use]
901 pub fn max_width(mut self, width: u16) -> Self {
902 self.config.max_width = width;
903 self
904 }
905
906 #[must_use]
908 pub fn margin(mut self, margin: u16) -> Self {
909 self.config.margin = margin;
910 self
911 }
912
913 #[must_use]
915 pub fn dismissable(mut self, dismissable: bool) -> Self {
916 self.config.dismissable = dismissable;
917 self
918 }
919
920 #[must_use]
922 pub fn style(mut self, style: Style) -> Self {
923 self.style = style;
924 self
925 }
926
927 #[must_use]
929 pub fn with_icon_style(mut self, style: Style) -> Self {
930 self.icon_style = style;
931 self
932 }
933
934 #[must_use]
936 pub fn with_title_style(mut self, style: Style) -> Self {
937 self.title_style = style;
938 self
939 }
940
941 #[must_use]
945 pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
946 self.config.animation.entrance = animation;
947 self
948 }
949
950 #[must_use]
952 pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
953 self.config.animation.exit = animation;
954 self
955 }
956
957 #[must_use]
959 pub fn entrance_duration(mut self, duration: Duration) -> Self {
960 self.config.animation.entrance_duration = duration;
961 self
962 }
963
964 #[must_use]
966 pub fn exit_duration(mut self, duration: Duration) -> Self {
967 self.config.animation.exit_duration = duration;
968 self
969 }
970
971 #[must_use]
973 pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
974 self.config.animation.entrance_easing = easing;
975 self
976 }
977
978 #[must_use]
980 pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
981 self.config.animation.exit_easing = easing;
982 self
983 }
984
985 #[must_use]
989 pub fn action(mut self, action: ToastAction) -> Self {
990 self.actions.push(action);
991 self
992 }
993
994 #[must_use]
996 pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
997 self.actions = actions;
998 self
999 }
1000
1001 #[must_use]
1003 pub fn with_action_style(mut self, style: Style) -> Self {
1004 self.action_style = style;
1005 self
1006 }
1007
1008 #[must_use]
1010 pub fn with_action_focus_style(mut self, style: Style) -> Self {
1011 self.action_focus_style = style;
1012 self
1013 }
1014
1015 #[must_use]
1017 pub fn no_animation(mut self) -> Self {
1018 self.config.animation = ToastAnimationConfig::none();
1019 self.state.animation = ToastAnimationState {
1020 phase: ToastAnimationPhase::Visible,
1021 phase_started: Instant::now(),
1022 reduced_motion: true,
1023 };
1024 self
1025 }
1026
1027 #[must_use]
1029 pub fn reduced_motion(mut self, enabled: bool) -> Self {
1030 self.config.animation.respect_reduced_motion = enabled;
1031 if enabled {
1032 self.state.animation = ToastAnimationState::with_reduced_motion();
1033 }
1034 self
1035 }
1036
1037 pub fn is_expired(&self) -> bool {
1043 if let Some(duration) = self.config.duration {
1044 let wall_elapsed = self.state.created_at.elapsed();
1045 let mut paused = self.state.total_paused;
1046 if self.state.timer_paused
1047 && let Some(pause_start) = self.state.pause_started
1048 {
1049 paused += pause_start.elapsed();
1050 }
1051 let effective_elapsed = wall_elapsed.saturating_sub(paused);
1052 effective_elapsed >= duration
1053 } else {
1054 false
1055 }
1056 }
1057
1058 #[inline]
1062 pub fn is_visible(&self) -> bool {
1063 self.state.animation.phase != ToastAnimationPhase::Hidden
1064 }
1065
1066 pub fn is_animating(&self) -> bool {
1068 matches!(
1069 self.state.animation.phase,
1070 ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1071 )
1072 }
1073
1074 pub fn dismiss(&mut self) {
1076 if !self.state.dismissed {
1077 self.state.dismissed = true;
1078 self.state.animation.start_exit();
1079 }
1080 }
1081
1082 pub fn dismiss_immediately(&mut self) {
1084 self.state.dismissed = true;
1085 self.state
1086 .animation
1087 .transition_to(ToastAnimationPhase::Hidden);
1088 }
1089
1090 pub fn tick_animation(&mut self) -> bool {
1094 self.state.animation.tick(&self.config.animation)
1095 }
1096
1097 pub fn animation_phase(&self) -> ToastAnimationPhase {
1099 self.state.animation.phase
1100 }
1101
1102 pub fn animation_offset(&self) -> (i16, i16) {
1106 let (width, height) = self.calculate_dimensions();
1107 self.state
1108 .animation
1109 .current_offset(&self.config.animation, width, height)
1110 }
1111
1112 pub fn animation_opacity(&self) -> f64 {
1114 self.state.animation.current_opacity(&self.config.animation)
1115 }
1116
1117 #[must_use = "use the remaining time (if any) for scheduling"]
1121 pub fn remaining_time(&self) -> Option<Duration> {
1122 self.config.duration.map(|d| {
1123 let wall_elapsed = self.state.created_at.elapsed();
1124 let mut paused = self.state.total_paused;
1125 if self.state.timer_paused
1126 && let Some(pause_start) = self.state.pause_started
1127 {
1128 paused += pause_start.elapsed();
1129 }
1130 let effective_elapsed = wall_elapsed.saturating_sub(paused);
1131 d.saturating_sub(effective_elapsed)
1132 })
1133 }
1134
1135 pub fn handle_key(&mut self, key: KeyEvent) -> ToastEvent {
1144 if !self.is_visible() || self.state.dismissed {
1145 return ToastEvent::None;
1146 }
1147
1148 match key {
1149 KeyEvent::Esc => {
1150 if self.has_focus() {
1151 self.clear_focus();
1152 ToastEvent::None
1153 } else if self.config.dismissable {
1154 self.dismiss();
1155 ToastEvent::Dismissed
1156 } else {
1157 ToastEvent::None
1158 }
1159 }
1160 KeyEvent::Tab => {
1161 if self.actions.is_empty() {
1162 return ToastEvent::None;
1163 }
1164 let next = match self.state.focused_action {
1165 None => 0,
1166 Some(i) => (i + 1) % self.actions.len(),
1167 };
1168 self.state.focused_action = Some(next);
1169 self.pause_timer();
1170 ToastEvent::FocusChanged
1171 }
1172 KeyEvent::Enter => {
1173 if let Some(idx) = self.state.focused_action
1174 && let Some(action) = self.actions.get(idx)
1175 {
1176 let id = action.id.clone();
1177 self.dismiss();
1178 return ToastEvent::Action(id);
1179 }
1180 ToastEvent::None
1181 }
1182 _ => ToastEvent::None,
1183 }
1184 }
1185
1186 pub fn pause_timer(&mut self) {
1188 if !self.state.timer_paused {
1189 self.state.timer_paused = true;
1190 self.state.pause_started = Some(Instant::now());
1191 }
1192 }
1193
1194 pub fn resume_timer(&mut self) {
1196 if self.state.timer_paused {
1197 if let Some(pause_start) = self.state.pause_started.take() {
1198 self.state.total_paused += pause_start.elapsed();
1199 }
1200 self.state.timer_paused = false;
1201 }
1202 }
1203
1204 pub fn clear_focus(&mut self) {
1206 self.state.focused_action = None;
1207 self.resume_timer();
1208 }
1209
1210 pub fn has_focus(&self) -> bool {
1212 self.state.focused_action.is_some()
1213 }
1214
1215 #[must_use = "use the focused action (if any)"]
1217 pub fn focused_action(&self) -> Option<&ToastAction> {
1218 self.state
1219 .focused_action
1220 .and_then(|idx| self.actions.get(idx))
1221 }
1222
1223 pub fn calculate_dimensions(&self) -> (u16, u16) {
1225 let max_width = self.config.max_width as usize;
1226
1227 let icon_width = self
1229 .content
1230 .icon
1231 .map(|icon| {
1232 let mut buf = [0u8; 4];
1233 let s = icon.as_char().encode_utf8(&mut buf);
1234 display_width(s) + 1
1235 })
1236 .unwrap_or(0); let message_width = display_width(self.content.message.as_str());
1238 let title_width = self
1239 .content
1240 .title
1241 .as_ref()
1242 .map(|t| display_width(t.as_str()))
1243 .unwrap_or(0);
1244
1245 let mut content_width = (icon_width + message_width).max(title_width);
1247
1248 if !self.actions.is_empty() {
1250 let actions_width: usize = self
1251 .actions
1252 .iter()
1253 .map(|a| a.display_width())
1254 .sum::<usize>()
1255 + self.actions.len().saturating_sub(1); content_width = content_width.max(actions_width);
1257 }
1258
1259 let total_width = content_width.saturating_add(4).min(max_width);
1261
1262 let has_title = self.content.title.is_some();
1264 let has_actions = !self.actions.is_empty();
1265 let height = 3 + u16::from(has_title) + u16::from(has_actions);
1266
1267 (total_width as u16, height)
1268 }
1269}
1270
1271impl Widget for Toast {
1272 fn render(&self, area: Rect, frame: &mut Frame) {
1273 #[cfg(feature = "tracing")]
1274 let _span = tracing::debug_span!(
1275 "widget_render",
1276 widget = "Toast",
1277 x = area.x,
1278 y = area.y,
1279 w = area.width,
1280 h = area.height
1281 )
1282 .entered();
1283
1284 if area.is_empty() {
1285 return;
1286 }
1287
1288 let (content_width, content_height) = self.calculate_dimensions();
1290 let width = area.width.min(content_width);
1291 let height = area.height.min(content_height);
1292
1293 if width < 3 || height < 3 {
1294 return; }
1296
1297 let render_area = Rect::new(area.x, area.y, width, height);
1298
1299 if !self.is_visible() {
1300 clear_text_area(frame, render_area, Style::default());
1301 return;
1302 }
1303
1304 let deg = frame.buffer.degradation;
1305 if !deg.render_content() {
1306 return;
1307 }
1308
1309 let base_style = if deg.apply_styling() {
1310 self.style
1311 } else {
1312 Style::default()
1313 };
1314 clear_text_area(frame, render_area, base_style);
1315
1316 let use_unicode = deg.use_unicode_borders();
1318 let (tl, tr, bl, br, h, v) = if use_unicode {
1319 (
1320 '\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{2500}', '\u{2502}',
1321 )
1322 } else {
1323 ('+', '+', '+', '+', '-', '|')
1324 };
1325
1326 let mut cell = Cell::from_char(tl);
1328 if deg.apply_styling() {
1329 crate::apply_style(&mut cell, self.style);
1330 }
1331 frame.buffer.set_fast(render_area.x, render_area.y, cell);
1332
1333 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1334 let mut cell = Cell::from_char(h);
1335 if deg.apply_styling() {
1336 crate::apply_style(&mut cell, self.style);
1337 }
1338 frame.buffer.set_fast(x, render_area.y, cell);
1339 }
1340
1341 let mut cell_tr = Cell::from_char(tr);
1342 if deg.apply_styling() {
1343 crate::apply_style(&mut cell_tr, self.style);
1344 }
1345 frame.buffer.set_fast(
1346 render_area.right().saturating_sub(1),
1347 render_area.y,
1348 cell_tr,
1349 );
1350
1351 let bottom_y = render_area.bottom().saturating_sub(1);
1353 let mut cell_bl = Cell::from_char(bl);
1354 if deg.apply_styling() {
1355 crate::apply_style(&mut cell_bl, self.style);
1356 }
1357 frame.buffer.set_fast(render_area.x, bottom_y, cell_bl);
1358
1359 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1360 let mut cell = Cell::from_char(h);
1361 if deg.apply_styling() {
1362 crate::apply_style(&mut cell, self.style);
1363 }
1364 frame.buffer.set_fast(x, bottom_y, cell);
1365 }
1366
1367 let mut cell_br = Cell::from_char(br);
1368 if deg.apply_styling() {
1369 crate::apply_style(&mut cell_br, self.style);
1370 }
1371 frame
1372 .buffer
1373 .set_fast(render_area.right().saturating_sub(1), bottom_y, cell_br);
1374
1375 for y in (render_area.y + 1)..bottom_y {
1377 let mut cell_l = Cell::from_char(v);
1378 if deg.apply_styling() {
1379 crate::apply_style(&mut cell_l, self.style);
1380 }
1381 frame.buffer.set_fast(render_area.x, y, cell_l);
1382
1383 let mut cell_r = Cell::from_char(v);
1384 if deg.apply_styling() {
1385 crate::apply_style(&mut cell_r, self.style);
1386 }
1387 frame
1388 .buffer
1389 .set_fast(render_area.right().saturating_sub(1), y, cell_r);
1390 }
1391
1392 let content_x = render_area.x + 1; let content_width = width.saturating_sub(2); let mut content_y = render_area.y + 1;
1396
1397 if let Some(ref title) = self.content.title {
1399 let title_style = if deg.apply_styling() {
1400 self.title_style.merge(&self.style)
1401 } else {
1402 Style::default()
1403 };
1404
1405 let title_style = if deg.apply_styling() {
1406 title_style
1407 } else {
1408 Style::default()
1409 };
1410 crate::draw_text_span(
1411 frame,
1412 content_x,
1413 content_y,
1414 title,
1415 title_style,
1416 content_x + content_width,
1417 );
1418 content_y += 1;
1419 }
1420
1421 let mut msg_x = content_x;
1423
1424 if let Some(icon) = self.content.icon {
1425 let icon_char = if use_unicode {
1426 icon.as_char()
1427 } else {
1428 icon.as_ascii()
1429 };
1430
1431 let icon_style = if deg.apply_styling() {
1432 self.icon_style.merge(&self.style)
1433 } else {
1434 Style::default()
1435 };
1436 let icon_str = icon_char.to_string();
1437 msg_x = crate::draw_text_span(
1438 frame,
1439 msg_x,
1440 content_y,
1441 &icon_str,
1442 icon_style,
1443 content_x + content_width,
1444 );
1445 msg_x = crate::draw_text_span(
1446 frame,
1447 msg_x,
1448 content_y,
1449 " ",
1450 Style::default(),
1451 content_x + content_width,
1452 );
1453 }
1454
1455 let msg_style = if deg.apply_styling() {
1457 self.style
1458 } else {
1459 Style::default()
1460 };
1461 crate::draw_text_span(
1462 frame,
1463 msg_x,
1464 content_y,
1465 &self.content.message,
1466 msg_style,
1467 content_x + content_width,
1468 );
1469
1470 if !self.actions.is_empty() {
1472 content_y += 1;
1473 let mut btn_x = content_x;
1474
1475 for (idx, action) in self.actions.iter().enumerate() {
1476 let is_focused = self.state.focused_action == Some(idx);
1477 let btn_style = if is_focused && deg.apply_styling() {
1478 self.action_focus_style.merge(&self.style)
1479 } else if deg.apply_styling() {
1480 self.action_style.merge(&self.style)
1481 } else {
1482 Style::default()
1483 };
1484
1485 let max_x = content_x + content_width;
1486 let label = format!("[{}]", action.label);
1487 btn_x = crate::draw_text_span(frame, btn_x, content_y, &label, btn_style, max_x);
1488
1489 if idx + 1 < self.actions.len() {
1491 btn_x = crate::draw_text_span(
1492 frame,
1493 btn_x,
1494 content_y,
1495 " ",
1496 Style::default(),
1497 max_x,
1498 );
1499 }
1500 }
1501 }
1502 }
1503
1504 fn is_essential(&self) -> bool {
1505 false
1507 }
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512 use super::*;
1513 use ftui_render::budget::DegradationLevel;
1514 use ftui_render::grapheme_pool::GraphemePool;
1515
1516 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
1517 frame
1518 .buffer
1519 .get(x, y)
1520 .copied()
1521 .expect("test cell should exist")
1522 }
1523
1524 fn line_text(frame: &Frame, y: u16, width: u16) -> String {
1525 (0..width)
1526 .map(|x| {
1527 frame
1528 .buffer
1529 .get(x, y)
1530 .and_then(|cell| cell.content.as_char())
1531 .unwrap_or(' ')
1532 })
1533 .collect()
1534 }
1535
1536 fn focused_action_id(toast: &Toast) -> &str {
1537 toast
1538 .focused_action()
1539 .expect("focused action should exist")
1540 .id
1541 .as_str()
1542 }
1543
1544 fn unwrap_remaining(remaining: Option<Duration>) -> Duration {
1545 remaining.expect("remaining duration should exist")
1546 }
1547
1548 #[test]
1549 fn test_toast_new() {
1550 let toast = Toast::new("Hello");
1551 assert_eq!(toast.content.message, "Hello");
1552 assert!(toast.content.icon.is_none());
1553 assert!(toast.content.title.is_none());
1554 assert!(!toast.config.duration_explicit);
1555 assert!(toast.is_visible());
1556 }
1557
1558 #[test]
1559 fn test_toast_builder() {
1560 let toast = Toast::new("Test message")
1561 .icon(ToastIcon::Success)
1562 .title("Success")
1563 .position(ToastPosition::BottomRight)
1564 .duration(Duration::from_secs(10))
1565 .max_width(60);
1566
1567 assert_eq!(toast.content.message, "Test message");
1568 assert_eq!(toast.content.icon, Some(ToastIcon::Success));
1569 assert_eq!(toast.content.title, Some("Success".to_string()));
1570 assert_eq!(toast.config.position, ToastPosition::BottomRight);
1571 assert_eq!(toast.config.duration, Some(Duration::from_secs(10)));
1572 assert!(toast.config.duration_explicit);
1573 assert_eq!(toast.config.max_width, 60);
1574 }
1575
1576 #[test]
1577 fn test_toast_persistent() {
1578 let toast = Toast::new("Persistent").persistent();
1579 assert!(toast.config.duration.is_none());
1580 assert!(toast.config.duration_explicit);
1581 assert!(!toast.is_expired());
1582 }
1583
1584 #[test]
1585 fn test_toast_dismiss() {
1586 let mut toast = Toast::new("Dismissable").no_animation();
1587 assert!(toast.is_visible());
1588 toast.dismiss();
1589 assert!(!toast.is_visible());
1590 assert!(toast.state.dismissed);
1591 }
1592
1593 #[test]
1594 fn test_toast_position_calculate() {
1595 let terminal_width = 80;
1596 let terminal_height = 24;
1597 let toast_width = 30;
1598 let toast_height = 3;
1599 let margin = 1;
1600
1601 let (x, y) = ToastPosition::TopLeft.calculate_position(
1603 terminal_width,
1604 terminal_height,
1605 toast_width,
1606 toast_height,
1607 margin,
1608 );
1609 assert_eq!(x, 1);
1610 assert_eq!(y, 1);
1611
1612 let (x, y) = ToastPosition::TopRight.calculate_position(
1614 terminal_width,
1615 terminal_height,
1616 toast_width,
1617 toast_height,
1618 margin,
1619 );
1620 assert_eq!(x, 80 - 30 - 1); assert_eq!(y, 1);
1622
1623 let (x, y) = ToastPosition::BottomRight.calculate_position(
1625 terminal_width,
1626 terminal_height,
1627 toast_width,
1628 toast_height,
1629 margin,
1630 );
1631 assert_eq!(x, 49);
1632 assert_eq!(y, 24 - 3 - 1); let (x, y) = ToastPosition::TopCenter.calculate_position(
1636 terminal_width,
1637 terminal_height,
1638 toast_width,
1639 toast_height,
1640 margin,
1641 );
1642 assert_eq!(x, (80 - 30) / 2); assert_eq!(y, 1);
1644 }
1645
1646 #[test]
1647 fn test_toast_icon_chars() {
1648 assert_eq!(ToastIcon::Success.as_char(), '\u{2713}');
1649 assert_eq!(ToastIcon::Error.as_char(), '\u{2717}');
1650 assert_eq!(ToastIcon::Warning.as_char(), '!');
1651 assert_eq!(ToastIcon::Info.as_char(), 'i');
1652 assert_eq!(ToastIcon::Custom('*').as_char(), '*');
1653
1654 assert_eq!(ToastIcon::Success.as_ascii(), '+');
1656 assert_eq!(ToastIcon::Error.as_ascii(), 'x');
1657 }
1658
1659 #[test]
1660 fn test_toast_dimensions() {
1661 let toast = Toast::new("Short");
1662 let (w, h) = toast.calculate_dimensions();
1663 assert_eq!(w, 9);
1665 assert_eq!(h, 3); let toast_with_title = Toast::new("Message").title("Title");
1668 let (_w, h) = toast_with_title.calculate_dimensions();
1669 assert_eq!(h, 4); }
1671
1672 #[test]
1673 fn test_toast_dimensions_with_icon() {
1674 let toast = Toast::new("Message").icon(ToastIcon::Success);
1675 let (w, _h) = toast.calculate_dimensions();
1676 let mut buf = [0u8; 4];
1677 let icon = ToastIcon::Success.as_char().encode_utf8(&mut buf);
1678 let expected = display_width(icon) + 1 + display_width("Message") + 4;
1679 assert_eq!(w, expected as u16);
1680 }
1681
1682 #[test]
1683 fn test_toast_dimensions_max_width() {
1684 let toast = Toast::new("This is a very long message that exceeds max width").max_width(20);
1685 let (w, _h) = toast.calculate_dimensions();
1686 assert!(w <= 20);
1687 }
1688
1689 #[test]
1690 fn test_toast_render_basic() {
1691 let toast = Toast::new("Hello");
1692 let area = Rect::new(0, 0, 15, 5);
1693 let mut pool = GraphemePool::new();
1694 let mut frame = Frame::new(15, 5, &mut pool);
1695 toast.render(area, &mut frame);
1696
1697 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); assert!(frame.buffer.get(1, 1).is_some()); }
1701
1702 #[test]
1703 fn test_toast_render_with_icon() {
1704 let toast = Toast::new("OK").icon(ToastIcon::Success);
1705 let area = Rect::new(0, 0, 10, 5);
1706 let mut pool = GraphemePool::new();
1707 let mut frame = Frame::new(10, 5, &mut pool);
1708 toast.render(area, &mut frame);
1709
1710 let icon_cell = cell_at(&frame, 1, 1);
1712 let ok = if let Some(ch) = icon_cell.content.as_char() {
1713 ch == '\u{2713}'
1714 } else if let Some(id) = icon_cell.content.grapheme_id() {
1715 frame.pool.get(id) == Some("\u{2713}")
1716 } else {
1717 false
1718 };
1719 assert!(ok, "expected toast icon cell to contain ✓");
1720 }
1721
1722 #[test]
1723 fn test_toast_render_with_title() {
1724 let toast = Toast::new("Body").title("Head");
1725 let area = Rect::new(0, 0, 15, 6);
1726 let mut pool = GraphemePool::new();
1727 let mut frame = Frame::new(15, 6, &mut pool);
1728 toast.render(area, &mut frame);
1729
1730 let title_cell = cell_at(&frame, 1, 1);
1732 assert_eq!(title_cell.content.as_char(), Some('H'));
1733 }
1734
1735 #[test]
1736 fn test_toast_render_zero_area() {
1737 let toast = Toast::new("Test");
1738 let area = Rect::new(0, 0, 0, 0);
1739 let mut pool = GraphemePool::new();
1740 let mut frame = Frame::new(1, 1, &mut pool);
1741 toast.render(area, &mut frame); }
1743
1744 #[test]
1745 fn test_toast_render_small_area() {
1746 let toast = Toast::new("Test");
1747 let area = Rect::new(0, 0, 2, 2);
1748 let mut pool = GraphemePool::new();
1749 let mut frame = Frame::new(2, 2, &mut pool);
1750 toast.render(area, &mut frame); }
1752
1753 #[test]
1754 fn test_toast_not_visible_when_dismissed_clears_previous_render_area() {
1755 let mut toast = Toast::new("Test").no_animation();
1756 let area = Rect::new(0, 0, 20, 5);
1757 let mut pool = GraphemePool::new();
1758 let mut frame = Frame::new(20, 5, &mut pool);
1759 let (toast_width, toast_height) = toast.calculate_dimensions();
1760
1761 toast.render(area, &mut frame);
1762 toast.dismiss();
1763
1764 toast.render(area, &mut frame);
1765
1766 for y in 0..toast_height.min(area.height) {
1767 for x in 0..toast_width.min(area.width) {
1768 assert_eq!(cell_at(&frame, x, y).content.as_char(), Some(' '));
1769 }
1770 }
1771 }
1772
1773 #[test]
1774 fn test_toast_is_not_essential() {
1775 let toast = Toast::new("Test");
1776 assert!(!toast.is_essential());
1777 }
1778
1779 #[test]
1780 fn test_toast_simple_borders_use_ascii() {
1781 let toast = Toast::new("Hello");
1782 let area = Rect::new(0, 0, 15, 5);
1783 let mut pool = GraphemePool::new();
1784 let mut frame = Frame::new(15, 5, &mut pool);
1785 frame.buffer.degradation = DegradationLevel::SimpleBorders;
1786 toast.render(area, &mut frame);
1787
1788 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('+'));
1789 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('-'));
1790 assert_eq!(cell_at(&frame, 0, 1).content.as_char(), Some('|'));
1791 }
1792
1793 #[test]
1794 fn test_toast_skeleton_is_noop() {
1795 let toast = Toast::new("Hello").style_variant(ToastStyle::Success);
1796 let area = Rect::new(0, 0, 15, 5);
1797 let mut pool = GraphemePool::new();
1798 let mut frame = Frame::new(15, 5, &mut pool);
1799 let mut expected_pool = GraphemePool::new();
1800 let expected = Frame::new(15, 5, &mut expected_pool);
1801 frame.buffer.degradation = DegradationLevel::Skeleton;
1802 toast.render(area, &mut frame);
1803
1804 for y in 0..5 {
1805 for x in 0..15 {
1806 assert_eq!(frame.buffer.get(x, y), expected.buffer.get(x, y));
1807 }
1808 }
1809 }
1810
1811 #[test]
1812 fn test_toast_render_shorter_message_clears_stale_suffix() {
1813 let area = Rect::new(0, 0, 20, 5);
1814 let mut pool = GraphemePool::new();
1815 let mut frame = Frame::new(20, 5, &mut pool);
1816
1817 Toast::new("Long message text")
1818 .max_width(18)
1819 .no_animation()
1820 .render(area, &mut frame);
1821 Toast::new("Hi")
1822 .max_width(18)
1823 .no_animation()
1824 .render(area, &mut frame);
1825
1826 assert_eq!(line_text(&frame, 1, 6), "│Hi │");
1827 }
1828
1829 #[test]
1830 fn test_toast_no_styling_shorter_title_and_message_clear_stale_text() {
1831 let area = Rect::new(0, 0, 18, 6);
1832 let mut pool = GraphemePool::new();
1833 let mut frame = Frame::new(18, 6, &mut pool);
1834
1835 Toast::new("Long body")
1836 .title("LongTitle")
1837 .max_width(16)
1838 .no_animation()
1839 .render(area, &mut frame);
1840
1841 frame.buffer.degradation = DegradationLevel::NoStyling;
1842 Toast::new("Ok")
1843 .title("Hi")
1844 .max_width(16)
1845 .no_animation()
1846 .render(area, &mut frame);
1847
1848 assert_eq!(line_text(&frame, 1, 6), "|Hi |");
1849 assert_eq!(line_text(&frame, 2, 6), "|Ok |");
1850 }
1851
1852 #[test]
1853 fn test_toast_id_uniqueness() {
1854 let toast1 = Toast::new("A");
1855 let toast2 = Toast::new("B");
1856 assert_ne!(toast1.id, toast2.id);
1857 }
1858
1859 #[test]
1860 fn test_toast_style_variants() {
1861 let success = Toast::new("OK").style_variant(ToastStyle::Success);
1862 let error = Toast::new("Fail").style_variant(ToastStyle::Error);
1863 let warning = Toast::new("Warn").style_variant(ToastStyle::Warning);
1864 let info = Toast::new("Info").style_variant(ToastStyle::Info);
1865 let neutral = Toast::new("Neutral").style_variant(ToastStyle::Neutral);
1866
1867 assert_eq!(success.config.style_variant, ToastStyle::Success);
1868 assert_eq!(error.config.style_variant, ToastStyle::Error);
1869 assert_eq!(warning.config.style_variant, ToastStyle::Warning);
1870 assert_eq!(info.config.style_variant, ToastStyle::Info);
1871 assert_eq!(neutral.config.style_variant, ToastStyle::Neutral);
1872 }
1873
1874 #[test]
1875 fn test_toast_content_builder() {
1876 let content = ToastContent::new("Message")
1877 .with_icon(ToastIcon::Warning)
1878 .with_title("Alert");
1879
1880 assert_eq!(content.message, "Message");
1881 assert_eq!(content.icon, Some(ToastIcon::Warning));
1882 assert_eq!(content.title, Some("Alert".to_string()));
1883 }
1884
1885 #[test]
1888 fn test_animation_phase_default() {
1889 let toast = Toast::new("Test");
1890 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
1891 }
1892
1893 #[test]
1894 fn test_animation_phase_reduced_motion() {
1895 let toast = Toast::new("Test").reduced_motion(true);
1896 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1897 assert!(toast.state.animation.reduced_motion);
1898 }
1899
1900 #[test]
1901 fn test_animation_no_animation() {
1902 let toast = Toast::new("Test").no_animation();
1903 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1904 assert!(toast.config.animation.is_disabled());
1905 }
1906
1907 #[test]
1908 fn test_entrance_animation_builder() {
1909 let toast = Toast::new("Test")
1910 .entrance_animation(ToastEntranceAnimation::SlideFromTop)
1911 .entrance_duration(Duration::from_millis(300))
1912 .entrance_easing(ToastEasing::Bounce);
1913
1914 assert_eq!(
1915 toast.config.animation.entrance,
1916 ToastEntranceAnimation::SlideFromTop
1917 );
1918 assert_eq!(
1919 toast.config.animation.entrance_duration,
1920 Duration::from_millis(300)
1921 );
1922 assert_eq!(toast.config.animation.entrance_easing, ToastEasing::Bounce);
1923 }
1924
1925 #[test]
1926 fn test_exit_animation_builder() {
1927 let toast = Toast::new("Test")
1928 .exit_animation(ToastExitAnimation::SlideOut)
1929 .exit_duration(Duration::from_millis(100))
1930 .exit_easing(ToastEasing::EaseInOut);
1931
1932 assert_eq!(toast.config.animation.exit, ToastExitAnimation::SlideOut);
1933 assert_eq!(
1934 toast.config.animation.exit_duration,
1935 Duration::from_millis(100)
1936 );
1937 assert_eq!(toast.config.animation.exit_easing, ToastEasing::EaseInOut);
1938 }
1939
1940 #[test]
1941 fn test_entrance_animation_offsets() {
1942 let width = 30u16;
1943 let height = 5u16;
1944
1945 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1947 assert_eq!(dx, 0);
1948 assert_eq!(dy, -(height as i16));
1949
1950 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(0.0, width, height);
1952 assert_eq!(dx, 0);
1953 assert_eq!(dy, -(height as i16));
1954
1955 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1957 assert_eq!(dx, 0);
1958 assert_eq!(dy, 0);
1959
1960 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.initial_offset(width, height);
1962 assert_eq!(dx, width as i16);
1963 assert_eq!(dy, 0);
1964 }
1965
1966 #[test]
1967 fn test_exit_animation_offsets() {
1968 let width = 30u16;
1969 let height = 5u16;
1970 let entrance = ToastEntranceAnimation::SlideFromRight;
1971
1972 let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1974 assert_eq!(dx, -(width as i16)); assert_eq!(dy, 0);
1976
1977 let (dx, dy) =
1979 ToastExitAnimation::SlideOut.offset_at_progress(0.0, width, height, entrance);
1980 assert_eq!(dx, 0);
1981 assert_eq!(dy, 0);
1982
1983 let (dx, dy) =
1985 ToastExitAnimation::SlideOut.offset_at_progress(1.0, width, height, entrance);
1986 assert_eq!(dx, -(width as i16));
1987 assert_eq!(dy, 0);
1988 }
1989
1990 #[test]
1991 fn test_easing_apply() {
1992 assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
1994
1995 assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
1997
1998 assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
2000
2001 for easing in [
2003 ToastEasing::Linear,
2004 ToastEasing::EaseIn,
2005 ToastEasing::EaseOut,
2006 ToastEasing::EaseInOut,
2007 ToastEasing::Bounce,
2008 ] {
2009 assert!((easing.apply(0.0) - 0.0).abs() < 0.001, "{:?} at 0", easing);
2010 assert!((easing.apply(1.0) - 1.0).abs() < 0.001, "{:?} at 1", easing);
2011 }
2012 }
2013
2014 #[test]
2015 fn test_animation_state_progress() {
2016 let state = ToastAnimationState::new();
2017 let progress = state.progress(Duration::from_millis(200));
2019 assert!(
2020 progress < 0.1,
2021 "Progress should be small immediately after creation"
2022 );
2023 }
2024
2025 #[test]
2026 fn test_animation_state_zero_duration() {
2027 let state = ToastAnimationState::new();
2028 let progress = state.progress(Duration::ZERO);
2030 assert_eq!(progress, 1.0);
2031 }
2032
2033 #[test]
2034 fn test_dismiss_starts_exit_animation() {
2035 let mut toast = Toast::new("Test").no_animation();
2036 toast.state.animation.phase = ToastAnimationPhase::Visible;
2038 toast.state.animation.reduced_motion = false;
2039
2040 toast.dismiss();
2041
2042 assert!(toast.state.dismissed);
2043 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Exiting);
2044 }
2045
2046 #[test]
2047 fn test_dismiss_immediately() {
2048 let mut toast = Toast::new("Test");
2049 toast.dismiss_immediately();
2050
2051 assert!(toast.state.dismissed);
2052 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Hidden);
2053 assert!(!toast.is_visible());
2054 }
2055
2056 #[test]
2057 fn test_is_animating() {
2058 let toast = Toast::new("Test");
2059 assert!(toast.is_animating()); let toast_visible = Toast::new("Test").no_animation();
2062 assert!(!toast_visible.is_animating()); }
2064
2065 #[test]
2066 fn test_animation_opacity_fade_in() {
2067 let config = ToastAnimationConfig {
2068 entrance: ToastEntranceAnimation::FadeIn,
2069 exit: ToastExitAnimation::FadeOut,
2070 entrance_duration: Duration::from_millis(200),
2071 exit_duration: Duration::from_millis(150),
2072 entrance_easing: ToastEasing::Linear,
2073 exit_easing: ToastEasing::Linear,
2074 respect_reduced_motion: false,
2075 };
2076
2077 let mut state = ToastAnimationState::new();
2079 let opacity = state.current_opacity(&config);
2080 assert!(opacity < 0.1, "Should be low opacity at start");
2081
2082 state.phase = ToastAnimationPhase::Visible;
2084 let opacity = state.current_opacity(&config);
2085 assert!((opacity - 1.0).abs() < 0.001);
2086 }
2087
2088 #[test]
2089 fn test_animation_config_default() {
2090 let config = ToastAnimationConfig::default();
2091
2092 assert_eq!(config.entrance, ToastEntranceAnimation::SlideFromRight);
2093 assert_eq!(config.exit, ToastExitAnimation::FadeOut);
2094 assert_eq!(config.entrance_duration, Duration::from_millis(200));
2095 assert_eq!(config.exit_duration, Duration::from_millis(150));
2096 assert!(config.respect_reduced_motion);
2097 }
2098
2099 #[test]
2100 fn test_animation_affects_position() {
2101 assert!(ToastEntranceAnimation::SlideFromTop.affects_position());
2102 assert!(ToastEntranceAnimation::SlideFromRight.affects_position());
2103 assert!(!ToastEntranceAnimation::FadeIn.affects_position());
2104 assert!(!ToastEntranceAnimation::None.affects_position());
2105
2106 assert!(ToastExitAnimation::SlideOut.affects_position());
2107 assert!(ToastExitAnimation::SlideToLeft.affects_position());
2108 assert!(!ToastExitAnimation::FadeOut.affects_position());
2109 assert!(!ToastExitAnimation::None.affects_position());
2110 }
2111
2112 #[test]
2113 fn test_toast_animation_offset() {
2114 let toast = Toast::new("Test").entrance_animation(ToastEntranceAnimation::SlideFromRight);
2115 let (dx, dy) = toast.animation_offset();
2116 assert!(dx > 0, "Should have positive x offset at start");
2118 assert_eq!(dy, 0);
2119 }
2120
2121 #[test]
2124 fn action_builder_single() {
2125 let toast = Toast::new("msg").action(ToastAction::new("Retry", "retry"));
2126 assert_eq!(toast.actions.len(), 1);
2127 assert_eq!(toast.actions[0].label, "Retry");
2128 assert_eq!(toast.actions[0].id, "retry");
2129 }
2130
2131 #[test]
2132 fn action_builder_multiple() {
2133 let toast = Toast::new("msg")
2134 .action(ToastAction::new("Ack", "ack"))
2135 .action(ToastAction::new("Snooze", "snooze"));
2136 assert_eq!(toast.actions.len(), 2);
2137 }
2138
2139 #[test]
2140 fn action_builder_vec() {
2141 let actions = vec![
2142 ToastAction::new("A", "a"),
2143 ToastAction::new("B", "b"),
2144 ToastAction::new("C", "c"),
2145 ];
2146 let toast = Toast::new("msg").actions(actions);
2147 assert_eq!(toast.actions.len(), 3);
2148 }
2149
2150 #[test]
2151 fn action_display_width() {
2152 let a = ToastAction::new("OK", "ok");
2153 assert_eq!(a.display_width(), 4);
2155 }
2156
2157 #[test]
2158 fn handle_key_esc_dismisses() {
2159 let mut toast = Toast::new("msg").no_animation();
2160 let result = toast.handle_key(KeyEvent::Esc);
2161 assert_eq!(result, ToastEvent::Dismissed);
2162 }
2163
2164 #[test]
2165 fn handle_key_esc_clears_focus_first() {
2166 let mut toast = Toast::new("msg")
2167 .action(ToastAction::new("A", "a"))
2168 .no_animation();
2169 toast.handle_key(KeyEvent::Tab);
2171 assert!(toast.has_focus());
2172 let result = toast.handle_key(KeyEvent::Esc);
2174 assert_eq!(result, ToastEvent::None);
2175 assert!(!toast.has_focus());
2176 }
2177
2178 #[test]
2179 fn handle_key_tab_cycles_focus() {
2180 let mut toast = Toast::new("msg")
2181 .action(ToastAction::new("A", "a"))
2182 .action(ToastAction::new("B", "b"))
2183 .no_animation();
2184
2185 let r1 = toast.handle_key(KeyEvent::Tab);
2186 assert_eq!(r1, ToastEvent::FocusChanged);
2187 assert_eq!(toast.state.focused_action, Some(0));
2188
2189 let r2 = toast.handle_key(KeyEvent::Tab);
2190 assert_eq!(r2, ToastEvent::FocusChanged);
2191 assert_eq!(toast.state.focused_action, Some(1));
2192
2193 let r3 = toast.handle_key(KeyEvent::Tab);
2195 assert_eq!(r3, ToastEvent::FocusChanged);
2196 assert_eq!(toast.state.focused_action, Some(0));
2197 }
2198
2199 #[test]
2200 fn handle_key_tab_no_actions_is_noop() {
2201 let mut toast = Toast::new("msg").no_animation();
2202 let result = toast.handle_key(KeyEvent::Tab);
2203 assert_eq!(result, ToastEvent::None);
2204 }
2205
2206 #[test]
2207 fn handle_key_enter_invokes_action() {
2208 let mut toast = Toast::new("msg")
2209 .action(ToastAction::new("Retry", "retry"))
2210 .no_animation();
2211 toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2213 assert_eq!(result, ToastEvent::Action("retry".into()));
2214 }
2215
2216 #[test]
2217 fn handle_key_enter_no_focus_is_noop() {
2218 let mut toast = Toast::new("msg")
2219 .action(ToastAction::new("A", "a"))
2220 .no_animation();
2221 let result = toast.handle_key(KeyEvent::Enter);
2222 assert_eq!(result, ToastEvent::None);
2223 }
2224
2225 #[test]
2226 fn handle_key_other_is_noop() {
2227 let mut toast = Toast::new("msg").no_animation();
2228 let result = toast.handle_key(KeyEvent::Other);
2229 assert_eq!(result, ToastEvent::None);
2230 }
2231
2232 #[test]
2233 fn handle_key_dismissed_toast_is_noop() {
2234 let mut toast = Toast::new("msg").no_animation();
2235 toast.state.dismissed = true;
2236 let result = toast.handle_key(KeyEvent::Esc);
2237 assert_eq!(result, ToastEvent::None);
2238 }
2239
2240 #[test]
2241 fn pause_timer_sets_flag() {
2242 let mut toast = Toast::new("msg").no_animation();
2243 toast.pause_timer();
2244 assert!(toast.state.timer_paused);
2245 assert!(toast.state.pause_started.is_some());
2246 }
2247
2248 #[test]
2249 fn resume_timer_accumulates_paused() {
2250 let mut toast = Toast::new("msg").no_animation();
2251 toast.pause_timer();
2252 std::thread::sleep(Duration::from_millis(10));
2253 toast.resume_timer();
2254 assert!(!toast.state.timer_paused);
2255 assert!(toast.state.total_paused >= Duration::from_millis(5));
2256 }
2257
2258 #[test]
2259 fn pause_resume_idempotent() {
2260 let mut toast = Toast::new("msg").no_animation();
2261 toast.pause_timer();
2263 toast.pause_timer();
2264 assert!(toast.state.timer_paused);
2265 toast.resume_timer();
2267 toast.resume_timer();
2268 assert!(!toast.state.timer_paused);
2269 }
2270
2271 #[test]
2272 fn clear_focus_resumes_timer() {
2273 let mut toast = Toast::new("msg")
2274 .action(ToastAction::new("A", "a"))
2275 .no_animation();
2276 toast.handle_key(KeyEvent::Tab);
2277 assert!(toast.state.timer_paused);
2278 toast.clear_focus();
2279 assert!(!toast.has_focus());
2280 assert!(!toast.state.timer_paused);
2281 }
2282
2283 #[test]
2284 fn focused_action_returns_correct() {
2285 let mut toast = Toast::new("msg")
2286 .action(ToastAction::new("X", "x"))
2287 .action(ToastAction::new("Y", "y"))
2288 .no_animation();
2289 assert!(toast.focused_action().is_none());
2290 toast.handle_key(KeyEvent::Tab);
2291 assert_eq!(focused_action_id(&toast), "x");
2292 toast.handle_key(KeyEvent::Tab);
2293 assert_eq!(focused_action_id(&toast), "y");
2294 }
2295
2296 #[test]
2297 fn is_expired_accounts_for_pause() {
2298 let mut toast = Toast::new("msg")
2299 .duration(Duration::from_millis(50))
2300 .no_animation();
2301 toast.pause_timer();
2302 std::thread::sleep(Duration::from_millis(60));
2304 assert!(
2305 !toast.is_expired(),
2306 "Should not expire while timer is paused"
2307 );
2308 toast.resume_timer();
2309 assert!(
2311 !toast.is_expired(),
2312 "Should not expire immediately after resume because paused time was subtracted"
2313 );
2314 }
2315
2316 #[test]
2317 fn dimensions_include_actions_row() {
2318 let toast = Toast::new("Hi")
2319 .action(ToastAction::new("OK", "ok"))
2320 .no_animation();
2321 let (_, h) = toast.calculate_dimensions();
2322 assert_eq!(h, 4);
2325 }
2326
2327 #[test]
2328 fn dimensions_with_title_and_actions() {
2329 let toast = Toast::new("Hi")
2330 .title("Title")
2331 .action(ToastAction::new("OK", "ok"))
2332 .no_animation();
2333 let (_, h) = toast.calculate_dimensions();
2334 assert_eq!(h, 5);
2336 }
2337
2338 #[test]
2339 fn dimensions_width_accounts_for_actions() {
2340 let toast = Toast::new("Hi")
2341 .action(ToastAction::new("LongButtonLabel", "lb"))
2342 .no_animation();
2343 let (w, _) = toast.calculate_dimensions();
2344 assert!(w >= 20);
2347 }
2348
2349 #[test]
2350 fn render_with_actions_does_not_panic() {
2351 let toast = Toast::new("Test")
2352 .action(ToastAction::new("OK", "ok"))
2353 .action(ToastAction::new("Cancel", "cancel"))
2354 .no_animation();
2355
2356 let mut pool = GraphemePool::new();
2357 let mut frame = Frame::new(60, 20, &mut pool);
2358 let area = Rect::new(0, 0, 40, 10);
2359 toast.render(area, &mut frame);
2360 }
2361
2362 #[test]
2363 fn render_focused_action_does_not_panic() {
2364 let mut toast = Toast::new("Test")
2365 .action(ToastAction::new("OK", "ok"))
2366 .no_animation();
2367 toast.handle_key(KeyEvent::Tab); let mut pool = GraphemePool::new();
2370 let mut frame = Frame::new(60, 20, &mut pool);
2371 let area = Rect::new(0, 0, 40, 10);
2372 toast.render(area, &mut frame);
2373 }
2374
2375 #[test]
2376 fn render_actions_tiny_area_does_not_panic() {
2377 let toast = Toast::new("X")
2378 .action(ToastAction::new("A", "a"))
2379 .no_animation();
2380
2381 let mut pool = GraphemePool::new();
2382 let mut frame = Frame::new(5, 3, &mut pool);
2383 let area = Rect::new(0, 0, 5, 3);
2384 toast.render(area, &mut frame);
2385 }
2386
2387 #[test]
2388 fn toast_action_styles() {
2389 let style = Style::new().bold();
2390 let focus_style = Style::new().italic();
2391 let toast = Toast::new("msg")
2392 .action(ToastAction::new("A", "a"))
2393 .with_action_style(style)
2394 .with_action_focus_style(focus_style);
2395 assert_eq!(toast.action_style, style);
2396 assert_eq!(toast.action_focus_style, focus_style);
2397 }
2398
2399 #[test]
2400 fn persistent_toast_not_expired_with_actions() {
2401 let toast = Toast::new("msg")
2402 .persistent()
2403 .action(ToastAction::new("Dismiss", "dismiss"))
2404 .no_animation();
2405 std::thread::sleep(Duration::from_millis(10));
2406 assert!(!toast.is_expired());
2407 }
2408
2409 #[test]
2410 fn action_invoke_second_button() {
2411 let mut toast = Toast::new("msg")
2412 .action(ToastAction::new("A", "a"))
2413 .action(ToastAction::new("B", "b"))
2414 .no_animation();
2415 toast.handle_key(KeyEvent::Tab); toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2418 assert_eq!(result, ToastEvent::Action("b".into()));
2419 }
2420
2421 #[test]
2422 fn remaining_time_with_pause() {
2423 let toast = Toast::new("msg")
2424 .duration(Duration::from_secs(10))
2425 .no_animation();
2426 let remaining = toast.remaining_time();
2427 assert!(remaining.is_some());
2428 let r = unwrap_remaining(remaining);
2429 assert!(r > Duration::from_secs(9));
2430 }
2431
2432 #[test]
2437 fn position_bottom_left() {
2438 let (x, y) = ToastPosition::BottomLeft.calculate_position(80, 24, 20, 3, 1);
2439 assert_eq!(x, 1);
2440 assert_eq!(y, 24 - 3 - 1); }
2442
2443 #[test]
2444 fn position_bottom_center() {
2445 let (x, y) = ToastPosition::BottomCenter.calculate_position(80, 24, 20, 3, 1);
2446 assert_eq!(x, (80 - 20) / 2); assert_eq!(y, 24 - 3 - 1); }
2449
2450 #[test]
2451 fn position_toast_wider_than_terminal_saturates() {
2452 let (x, y) = ToastPosition::TopRight.calculate_position(20, 10, 30, 3, 1);
2454 assert_eq!(x, 0); assert_eq!(y, 1);
2456 }
2457
2458 #[test]
2459 fn position_zero_margin() {
2460 let (x, y) = ToastPosition::TopLeft.calculate_position(80, 24, 20, 3, 0);
2461 assert_eq!(x, 0);
2462 assert_eq!(y, 0);
2463
2464 let (x, y) = ToastPosition::BottomRight.calculate_position(80, 24, 20, 3, 0);
2465 assert_eq!(x, 60);
2466 assert_eq!(y, 21);
2467 }
2468
2469 #[test]
2470 fn position_toast_taller_than_terminal_saturates() {
2471 let (_, y) = ToastPosition::BottomLeft.calculate_position(80, 3, 20, 10, 1);
2472 assert_eq!(y, 0); }
2474
2475 #[test]
2480 fn icon_custom_non_ascii_falls_back_to_star() {
2481 let icon = ToastIcon::Custom('\u{1F525}'); assert_eq!(icon.as_char(), '\u{1F525}');
2483 assert_eq!(icon.as_ascii(), '*');
2484 }
2485
2486 #[test]
2487 fn icon_custom_ascii_preserved() {
2488 let icon = ToastIcon::Custom('#');
2489 assert_eq!(icon.as_char(), '#');
2490 assert_eq!(icon.as_ascii(), '#');
2491 }
2492
2493 #[test]
2494 fn icon_warning_ascii_same() {
2495 assert_eq!(ToastIcon::Warning.as_ascii(), '!');
2496 assert_eq!(ToastIcon::Info.as_ascii(), 'i');
2497 }
2498
2499 #[test]
2504 fn toast_position_default_is_top_right() {
2505 assert_eq!(ToastPosition::default(), ToastPosition::TopRight);
2506 }
2507
2508 #[test]
2509 fn toast_icon_default_is_info() {
2510 assert_eq!(ToastIcon::default(), ToastIcon::Info);
2511 }
2512
2513 #[test]
2514 fn toast_style_default_is_info() {
2515 assert_eq!(ToastStyle::default(), ToastStyle::Info);
2516 }
2517
2518 #[test]
2519 fn toast_animation_phase_default_is_visible() {
2520 assert_eq!(ToastAnimationPhase::default(), ToastAnimationPhase::Visible);
2521 }
2522
2523 #[test]
2524 fn toast_entrance_animation_default_is_slide_from_right() {
2525 assert_eq!(
2526 ToastEntranceAnimation::default(),
2527 ToastEntranceAnimation::SlideFromRight
2528 );
2529 }
2530
2531 #[test]
2532 fn toast_exit_animation_default_is_fade_out() {
2533 assert_eq!(ToastExitAnimation::default(), ToastExitAnimation::FadeOut);
2534 }
2535
2536 #[test]
2537 fn toast_easing_default_is_ease_out() {
2538 assert_eq!(ToastEasing::default(), ToastEasing::EaseOut);
2539 }
2540
2541 #[test]
2546 fn entrance_slide_from_bottom_offset() {
2547 let (dx, dy) = ToastEntranceAnimation::SlideFromBottom.initial_offset(20, 5);
2548 assert_eq!(dx, 0);
2549 assert_eq!(dy, 5); }
2551
2552 #[test]
2553 fn entrance_slide_from_left_offset() {
2554 let (dx, dy) = ToastEntranceAnimation::SlideFromLeft.initial_offset(20, 5);
2555 assert_eq!(dx, -20);
2556 assert_eq!(dy, 0);
2557 }
2558
2559 #[test]
2560 fn entrance_fade_in_no_offset() {
2561 let (dx, dy) = ToastEntranceAnimation::FadeIn.initial_offset(20, 5);
2562 assert_eq!(dx, 0);
2563 assert_eq!(dy, 0);
2564 }
2565
2566 #[test]
2567 fn entrance_none_no_offset() {
2568 let (dx, dy) = ToastEntranceAnimation::None.initial_offset(20, 5);
2569 assert_eq!(dx, 0);
2570 assert_eq!(dy, 0);
2571 }
2572
2573 #[test]
2574 fn entrance_offset_progress_clamped() {
2575 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(-0.5, 20, 5);
2577 assert_eq!(dx, 0);
2578 assert_eq!(dy, -5); let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(2.0, 20, 5);
2582 assert_eq!(dx, 0);
2583 assert_eq!(dy, 0); }
2585
2586 #[test]
2587 fn entrance_offset_at_half_progress() {
2588 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.offset_at_progress(0.5, 20, 5);
2589 assert_eq!(dx, 10); assert_eq!(dy, 0);
2591 }
2592
2593 #[test]
2598 fn exit_slide_to_top_offset() {
2599 let entrance = ToastEntranceAnimation::SlideFromRight;
2600 let (dx, dy) = ToastExitAnimation::SlideToTop.final_offset(20, 5, entrance);
2601 assert_eq!(dx, 0);
2602 assert_eq!(dy, -5);
2603 }
2604
2605 #[test]
2606 fn exit_slide_to_right_offset() {
2607 let entrance = ToastEntranceAnimation::SlideFromRight;
2608 let (dx, dy) = ToastExitAnimation::SlideToRight.final_offset(20, 5, entrance);
2609 assert_eq!(dx, 20);
2610 assert_eq!(dy, 0);
2611 }
2612
2613 #[test]
2614 fn exit_slide_to_bottom_offset() {
2615 let entrance = ToastEntranceAnimation::SlideFromRight;
2616 let (dx, dy) = ToastExitAnimation::SlideToBottom.final_offset(20, 5, entrance);
2617 assert_eq!(dx, 0);
2618 assert_eq!(dy, 5);
2619 }
2620
2621 #[test]
2622 fn exit_slide_to_left_offset() {
2623 let entrance = ToastEntranceAnimation::SlideFromRight;
2624 let (dx, dy) = ToastExitAnimation::SlideToLeft.final_offset(20, 5, entrance);
2625 assert_eq!(dx, -20);
2626 assert_eq!(dy, 0);
2627 }
2628
2629 #[test]
2630 fn exit_fade_out_no_offset() {
2631 let entrance = ToastEntranceAnimation::SlideFromRight;
2632 let (dx, dy) = ToastExitAnimation::FadeOut.final_offset(20, 5, entrance);
2633 assert_eq!(dx, 0);
2634 assert_eq!(dy, 0);
2635 }
2636
2637 #[test]
2638 fn exit_none_no_offset() {
2639 let entrance = ToastEntranceAnimation::SlideFromRight;
2640 let (dx, dy) = ToastExitAnimation::None.final_offset(20, 5, entrance);
2641 assert_eq!(dx, 0);
2642 assert_eq!(dy, 0);
2643 }
2644
2645 #[test]
2646 fn exit_offset_progress_clamped() {
2647 let entrance = ToastEntranceAnimation::SlideFromRight;
2648 let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(-1.0, 20, 5, entrance);
2649 assert_eq!((dx, dy), (0, 0)); let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(5.0, 20, 5, entrance);
2652 assert_eq!((dx, dy), (0, -5)); }
2654
2655 #[test]
2660 fn easing_clamped_below_zero() {
2661 for easing in [
2662 ToastEasing::Linear,
2663 ToastEasing::EaseIn,
2664 ToastEasing::EaseOut,
2665 ToastEasing::EaseInOut,
2666 ToastEasing::Bounce,
2667 ] {
2668 let result = easing.apply(-0.5);
2669 assert!(
2670 (result - 0.0).abs() < 0.001,
2671 "{easing:?} at -0.5 should clamp to 0"
2672 );
2673 }
2674 }
2675
2676 #[test]
2677 fn easing_clamped_above_one() {
2678 for easing in [
2679 ToastEasing::Linear,
2680 ToastEasing::EaseIn,
2681 ToastEasing::EaseOut,
2682 ToastEasing::EaseInOut,
2683 ToastEasing::Bounce,
2684 ] {
2685 let result = easing.apply(1.5);
2686 assert!(
2687 (result - 1.0).abs() < 0.001,
2688 "{easing:?} at 1.5 should clamp to 1"
2689 );
2690 }
2691 }
2692
2693 #[test]
2694 fn easing_ease_in_out_first_half() {
2695 let result = ToastEasing::EaseInOut.apply(0.25);
2696 assert!(
2697 result < 0.25,
2698 "EaseInOut at 0.25 should be < 0.25 (accelerating)"
2699 );
2700 }
2701
2702 #[test]
2703 fn easing_ease_in_out_second_half() {
2704 let result = ToastEasing::EaseInOut.apply(0.75);
2705 assert!(
2706 result > 0.75,
2707 "EaseInOut at 0.75 should be > 0.75 (decelerating)"
2708 );
2709 }
2710
2711 #[test]
2712 fn easing_bounce_monotonic_at_key_points() {
2713 let d1 = 2.75;
2714 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);
2721 let v2 = ToastEasing::Bounce.apply(t2);
2722 let v3 = ToastEasing::Bounce.apply(t3);
2723 let v4 = ToastEasing::Bounce.apply(t4);
2724
2725 assert!((0.0..=1.0).contains(&v1), "branch 1: {v1}");
2726 assert!((0.0..=1.0).contains(&v2), "branch 2: {v2}");
2727 assert!((0.0..=1.0).contains(&v3), "branch 3: {v3}");
2728 assert!((0.0..=1.0).contains(&v4), "branch 4: {v4}");
2729 }
2730
2731 #[test]
2736 fn animation_state_tick_entering_to_visible() {
2737 let config = ToastAnimationConfig {
2738 entrance_duration: Duration::ZERO, ..ToastAnimationConfig::default()
2740 };
2741 let mut state = ToastAnimationState::new();
2742 assert_eq!(state.phase, ToastAnimationPhase::Entering);
2743
2744 let changed = state.tick(&config);
2745 assert!(changed, "Phase should change from Entering to Visible");
2746 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2747 }
2748
2749 #[test]
2750 fn animation_state_tick_exiting_to_hidden() {
2751 let config = ToastAnimationConfig {
2752 exit_duration: Duration::ZERO,
2753 ..ToastAnimationConfig::default()
2754 };
2755 let mut state = ToastAnimationState::new();
2756 state.transition_to(ToastAnimationPhase::Exiting);
2757
2758 let changed = state.tick(&config);
2759 assert!(changed, "Phase should change from Exiting to Hidden");
2760 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2761 }
2762
2763 #[test]
2764 fn animation_state_tick_visible_no_change() {
2765 let config = ToastAnimationConfig::default();
2766 let mut state = ToastAnimationState::new();
2767 state.transition_to(ToastAnimationPhase::Visible);
2768
2769 let changed = state.tick(&config);
2770 assert!(!changed, "Visible phase should not auto-transition");
2771 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2772 }
2773
2774 #[test]
2775 fn animation_state_tick_hidden_no_change() {
2776 let config = ToastAnimationConfig::default();
2777 let mut state = ToastAnimationState::new();
2778 state.transition_to(ToastAnimationPhase::Hidden);
2779
2780 let changed = state.tick(&config);
2781 assert!(!changed);
2782 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2783 }
2784
2785 #[test]
2786 fn animation_state_start_exit_reduced_motion_goes_to_hidden() {
2787 let mut state = ToastAnimationState::with_reduced_motion();
2788 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2789 state.start_exit();
2790 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2791 }
2792
2793 #[test]
2794 fn animation_state_is_complete() {
2795 let mut state = ToastAnimationState::new();
2796 assert!(!state.is_complete());
2797 state.transition_to(ToastAnimationPhase::Hidden);
2798 assert!(state.is_complete());
2799 }
2800
2801 #[test]
2806 fn animation_offset_visible_is_zero() {
2807 let config = ToastAnimationConfig::default();
2808 let mut state = ToastAnimationState::new();
2809 state.phase = ToastAnimationPhase::Visible;
2810 let (dx, dy) = state.current_offset(&config, 20, 5);
2811 assert_eq!((dx, dy), (0, 0));
2812 }
2813
2814 #[test]
2815 fn animation_offset_hidden_is_zero() {
2816 let config = ToastAnimationConfig::default();
2817 let mut state = ToastAnimationState::new();
2818 state.phase = ToastAnimationPhase::Hidden;
2819 let (dx, dy) = state.current_offset(&config, 20, 5);
2820 assert_eq!((dx, dy), (0, 0));
2821 }
2822
2823 #[test]
2824 fn animation_offset_reduced_motion_always_zero() {
2825 let config = ToastAnimationConfig::default();
2826 let state = ToastAnimationState::with_reduced_motion();
2827 let (dx, dy) = state.current_offset(&config, 20, 5);
2828 assert_eq!((dx, dy), (0, 0));
2829 }
2830
2831 #[test]
2832 fn animation_opacity_visible_is_one() {
2833 let config = ToastAnimationConfig::default();
2834 let mut state = ToastAnimationState::new();
2835 state.phase = ToastAnimationPhase::Visible;
2836 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2837 }
2838
2839 #[test]
2840 fn animation_opacity_hidden_is_zero() {
2841 let config = ToastAnimationConfig::default();
2842 let mut state = ToastAnimationState::new();
2843 state.phase = ToastAnimationPhase::Hidden;
2844 assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2845 }
2846
2847 #[test]
2848 fn animation_opacity_reduced_motion_visible_is_one() {
2849 let config = ToastAnimationConfig::default();
2850 let mut state = ToastAnimationState::with_reduced_motion();
2851 state.phase = ToastAnimationPhase::Visible;
2852 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2853 }
2854
2855 #[test]
2856 fn animation_opacity_reduced_motion_hidden_is_zero() {
2857 let config = ToastAnimationConfig::default();
2858 let mut state = ToastAnimationState::with_reduced_motion();
2859 state.phase = ToastAnimationPhase::Hidden;
2860 assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2861 }
2862
2863 #[test]
2864 fn animation_opacity_exiting_non_fade_is_one() {
2865 let config = ToastAnimationConfig {
2866 exit: ToastExitAnimation::SlideOut,
2867 ..ToastAnimationConfig::default()
2868 };
2869 let mut state = ToastAnimationState::new();
2870 state.phase = ToastAnimationPhase::Exiting;
2871 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2873 }
2874
2875 #[test]
2876 fn animation_opacity_entering_non_fade_is_one() {
2877 let config = ToastAnimationConfig {
2878 entrance: ToastEntranceAnimation::SlideFromTop,
2879 ..ToastAnimationConfig::default()
2880 };
2881 let mut state = ToastAnimationState::new();
2882 state.phase = ToastAnimationPhase::Entering;
2883 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2885 }
2886
2887 #[test]
2892 fn toast_with_id() {
2893 let toast = Toast::with_id(ToastId::new(42), "Custom ID");
2894 assert_eq!(toast.id, ToastId::new(42));
2895 assert_eq!(toast.content.message, "Custom ID");
2896 }
2897
2898 #[test]
2899 fn toast_tick_animation_returns_true_on_phase_change() {
2900 let mut toast = Toast::new("Test").entrance_duration(Duration::ZERO);
2901 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
2902 let changed = toast.tick_animation();
2903 assert!(changed);
2904 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2905 }
2906
2907 #[test]
2908 fn toast_tick_animation_returns_false_when_stable() {
2909 let mut toast = Toast::new("Test").no_animation();
2910 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2911 let changed = toast.tick_animation();
2912 assert!(!changed);
2913 }
2914
2915 #[test]
2916 fn toast_animation_phase_accessor() {
2917 let toast = Toast::new("Test").no_animation();
2918 assert_eq!(toast.animation_phase(), ToastAnimationPhase::Visible);
2919 }
2920
2921 #[test]
2922 fn toast_animation_opacity_accessor() {
2923 let toast = Toast::new("Test").no_animation();
2924 assert!((toast.animation_opacity() - 1.0).abs() < 0.001);
2925 }
2926
2927 #[test]
2928 fn toast_remaining_time_persistent_is_none() {
2929 let toast = Toast::new("msg").persistent().no_animation();
2930 assert!(toast.remaining_time().is_none());
2931 }
2932
2933 #[test]
2934 fn toast_dismiss_twice_idempotent() {
2935 let mut toast = Toast::new("msg").no_animation();
2936 toast.state.animation.reduced_motion = false;
2937 toast.dismiss();
2938 assert!(toast.state.dismissed);
2939 let phase_after_first = toast.state.animation.phase;
2940 toast.dismiss(); assert_eq!(toast.state.animation.phase, phase_after_first);
2942 }
2943
2944 #[test]
2945 fn toast_non_dismissable_esc_noop() {
2946 let mut toast = Toast::new("msg").dismissable(false).no_animation();
2947 let result = toast.handle_key(KeyEvent::Esc);
2948 assert_eq!(result, ToastEvent::None);
2949 assert!(toast.is_visible());
2950 }
2951
2952 #[test]
2953 fn toast_margin_builder() {
2954 let toast = Toast::new("msg").margin(5);
2955 assert_eq!(toast.config.margin, 5);
2956 }
2957
2958 #[test]
2959 fn toast_with_icon_style_builder() {
2960 let style = Style::new().italic();
2961 let toast = Toast::new("msg").with_icon_style(style);
2962 assert_eq!(toast.icon_style, style);
2963 }
2964
2965 #[test]
2966 fn toast_with_title_style_builder() {
2967 let style = Style::new().bold();
2968 let toast = Toast::new("msg").with_title_style(style);
2969 assert_eq!(toast.title_style, style);
2970 }
2971
2972 #[test]
2977 fn toast_config_default_values() {
2978 let config = ToastConfig::default();
2979 assert_eq!(config.position, ToastPosition::TopRight);
2980 assert_eq!(config.duration, Some(Duration::from_secs(5)));
2981 assert!(!config.duration_explicit);
2982 assert_eq!(config.style_variant, ToastStyle::Info);
2983 assert_eq!(config.max_width, 50);
2984 assert_eq!(config.margin, 1);
2985 assert!(config.dismissable);
2986 }
2987
2988 #[test]
2993 fn animation_config_none_fields() {
2994 let config = ToastAnimationConfig::none();
2995 assert_eq!(config.entrance, ToastEntranceAnimation::None);
2996 assert_eq!(config.exit, ToastExitAnimation::None);
2997 assert_eq!(config.entrance_duration, Duration::ZERO);
2998 assert_eq!(config.exit_duration, Duration::ZERO);
2999 assert!(config.is_disabled());
3000 }
3001
3002 #[test]
3003 fn animation_config_is_disabled_false_for_default() {
3004 let config = ToastAnimationConfig::default();
3005 assert!(!config.is_disabled());
3006 }
3007
3008 #[test]
3013 fn toast_id_hash_consistent() {
3014 use std::collections::HashSet;
3015 let mut set = HashSet::new();
3016 set.insert(ToastId::new(1));
3017 set.insert(ToastId::new(2));
3018 set.insert(ToastId::new(1)); assert_eq!(set.len(), 2);
3020 }
3021
3022 #[test]
3023 fn toast_id_debug() {
3024 let id = ToastId::new(42);
3025 let dbg = format!("{:?}", id);
3026 assert!(dbg.contains("42"), "Debug: {dbg}");
3027 }
3028
3029 #[test]
3030 fn toast_event_debug_clone() {
3031 let event = ToastEvent::Action("test".into());
3032 let dbg = format!("{:?}", event);
3033 assert!(dbg.contains("Action"), "Debug: {dbg}");
3034 let cloned = event.clone();
3035 assert_eq!(cloned, ToastEvent::Action("test".into()));
3036 }
3037
3038 #[test]
3039 fn key_event_traits() {
3040 let key = KeyEvent::Tab;
3041 let copy = key; assert_eq!(key, copy);
3043 let dbg = format!("{:?}", key);
3044 assert!(dbg.contains("Tab"), "Debug: {dbg}");
3045 }
3046
3047 #[test]
3052 fn animation_tick_entering_reduced_motion_transitions_immediately() {
3053 let config = ToastAnimationConfig::default();
3054 let mut state = ToastAnimationState {
3055 phase: ToastAnimationPhase::Entering,
3056 phase_started: Instant::now(),
3057 reduced_motion: true,
3058 };
3059 let changed = state.tick(&config);
3061 assert!(changed);
3062 assert_eq!(state.phase, ToastAnimationPhase::Visible);
3063 }
3064
3065 #[test]
3066 fn animation_tick_exiting_reduced_motion_transitions_immediately() {
3067 let config = ToastAnimationConfig::default();
3068 let mut state = ToastAnimationState {
3069 phase: ToastAnimationPhase::Exiting,
3070 phase_started: Instant::now(),
3071 reduced_motion: true,
3072 };
3073 let changed = state.tick(&config);
3074 assert!(changed);
3075 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
3076 }
3077}