1#![forbid(unsafe_code)]
2
3use std::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 pub fn with_icon(mut self, icon: ToastIcon) -> Self {
711 self.icon = Some(icon);
712 self
713 }
714
715 pub fn with_title(mut self, title: impl Into<String>) -> Self {
717 self.title = Some(title.into());
718 self
719 }
720}
721
722#[derive(Debug, Clone)]
724pub struct ToastState {
725 pub created_at: Instant,
727 pub dismissed: bool,
729 pub animation: ToastAnimationState,
731 pub focused_action: Option<usize>,
733 pub timer_paused: bool,
735 pub pause_started: Option<Instant>,
737 pub total_paused: Duration,
739}
740
741impl Default for ToastState {
742 fn default() -> Self {
743 Self {
744 created_at: Instant::now(),
745 dismissed: false,
746 animation: ToastAnimationState::default(),
747 focused_action: None,
748 timer_paused: false,
749 pause_started: None,
750 total_paused: Duration::ZERO,
751 }
752 }
753}
754
755impl ToastState {
756 pub fn with_reduced_motion() -> Self {
758 Self {
759 created_at: Instant::now(),
760 dismissed: false,
761 animation: ToastAnimationState::with_reduced_motion(),
762 focused_action: None,
763 timer_paused: false,
764 pause_started: None,
765 total_paused: Duration::ZERO,
766 }
767 }
768}
769
770#[derive(Debug, Clone)]
788pub struct Toast {
789 pub id: ToastId,
791 pub content: ToastContent,
793 pub config: ToastConfig,
795 pub state: ToastState,
797 pub actions: Vec<ToastAction>,
799 style: Style,
801 icon_style: Style,
803 title_style: Style,
805 action_style: Style,
807 action_focus_style: Style,
809}
810
811impl Toast {
812 pub fn new(message: impl Into<String>) -> Self {
814 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
815 let id = ToastId::new(NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
816
817 Self {
818 id,
819 content: ToastContent::new(message),
820 config: ToastConfig::default(),
821 state: ToastState::default(),
822 actions: Vec::new(),
823 style: Style::default(),
824 icon_style: Style::default(),
825 title_style: Style::default(),
826 action_style: Style::default(),
827 action_focus_style: Style::default(),
828 }
829 }
830
831 pub fn with_id(id: ToastId, message: impl Into<String>) -> Self {
833 Self {
834 id,
835 content: ToastContent::new(message),
836 config: ToastConfig::default(),
837 state: ToastState::default(),
838 actions: Vec::new(),
839 style: Style::default(),
840 icon_style: Style::default(),
841 title_style: Style::default(),
842 action_style: Style::default(),
843 action_focus_style: Style::default(),
844 }
845 }
846
847 pub fn icon(mut self, icon: ToastIcon) -> Self {
851 self.content.icon = Some(icon);
852 self
853 }
854
855 pub fn title(mut self, title: impl Into<String>) -> Self {
857 self.content.title = Some(title.into());
858 self
859 }
860
861 pub fn position(mut self, position: ToastPosition) -> Self {
863 self.config.position = position;
864 self
865 }
866
867 pub fn duration(mut self, duration: Duration) -> Self {
869 self.config.duration = Some(duration);
870 self
871 }
872
873 pub fn persistent(mut self) -> Self {
875 self.config.duration = None;
876 self
877 }
878
879 pub fn style_variant(mut self, variant: ToastStyle) -> Self {
881 self.config.style_variant = variant;
882 self
883 }
884
885 pub fn max_width(mut self, width: u16) -> Self {
887 self.config.max_width = width;
888 self
889 }
890
891 pub fn margin(mut self, margin: u16) -> Self {
893 self.config.margin = margin;
894 self
895 }
896
897 pub fn dismissable(mut self, dismissable: bool) -> Self {
899 self.config.dismissable = dismissable;
900 self
901 }
902
903 pub fn style(mut self, style: Style) -> Self {
905 self.style = style;
906 self
907 }
908
909 pub fn with_icon_style(mut self, style: Style) -> Self {
911 self.icon_style = style;
912 self
913 }
914
915 pub fn with_title_style(mut self, style: Style) -> Self {
917 self.title_style = style;
918 self
919 }
920
921 pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
925 self.config.animation.entrance = animation;
926 self
927 }
928
929 pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
931 self.config.animation.exit = animation;
932 self
933 }
934
935 pub fn entrance_duration(mut self, duration: Duration) -> Self {
937 self.config.animation.entrance_duration = duration;
938 self
939 }
940
941 pub fn exit_duration(mut self, duration: Duration) -> Self {
943 self.config.animation.exit_duration = duration;
944 self
945 }
946
947 pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
949 self.config.animation.entrance_easing = easing;
950 self
951 }
952
953 pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
955 self.config.animation.exit_easing = easing;
956 self
957 }
958
959 pub fn action(mut self, action: ToastAction) -> Self {
963 self.actions.push(action);
964 self
965 }
966
967 pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
969 self.actions = actions;
970 self
971 }
972
973 pub fn with_action_style(mut self, style: Style) -> Self {
975 self.action_style = style;
976 self
977 }
978
979 pub fn with_action_focus_style(mut self, style: Style) -> Self {
981 self.action_focus_style = style;
982 self
983 }
984
985 pub fn no_animation(mut self) -> Self {
987 self.config.animation = ToastAnimationConfig::none();
988 self.state.animation = ToastAnimationState {
989 phase: ToastAnimationPhase::Visible,
990 phase_started: Instant::now(),
991 reduced_motion: true,
992 };
993 self
994 }
995
996 pub fn reduced_motion(mut self, enabled: bool) -> Self {
998 self.config.animation.respect_reduced_motion = enabled;
999 if enabled {
1000 self.state.animation = ToastAnimationState::with_reduced_motion();
1001 }
1002 self
1003 }
1004
1005 pub fn is_expired(&self) -> bool {
1011 if let Some(duration) = self.config.duration {
1012 let wall_elapsed = self.state.created_at.elapsed();
1013 let mut paused = self.state.total_paused;
1014 if self.state.timer_paused
1015 && let Some(pause_start) = self.state.pause_started
1016 {
1017 paused += pause_start.elapsed();
1018 }
1019 let effective_elapsed = wall_elapsed.saturating_sub(paused);
1020 effective_elapsed >= duration
1021 } else {
1022 false
1023 }
1024 }
1025
1026 pub fn is_visible(&self) -> bool {
1031 !self.state.dismissed
1032 && !self.is_expired()
1033 && self.state.animation.phase != ToastAnimationPhase::Hidden
1034 }
1035
1036 pub fn is_animating(&self) -> bool {
1038 matches!(
1039 self.state.animation.phase,
1040 ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1041 )
1042 }
1043
1044 pub fn dismiss(&mut self) {
1046 if !self.state.dismissed {
1047 self.state.dismissed = true;
1048 self.state.animation.start_exit();
1049 }
1050 }
1051
1052 pub fn dismiss_immediately(&mut self) {
1054 self.state.dismissed = true;
1055 self.state
1056 .animation
1057 .transition_to(ToastAnimationPhase::Hidden);
1058 }
1059
1060 pub fn tick_animation(&mut self) -> bool {
1064 self.state.animation.tick(&self.config.animation)
1065 }
1066
1067 pub fn animation_phase(&self) -> ToastAnimationPhase {
1069 self.state.animation.phase
1070 }
1071
1072 pub fn animation_offset(&self) -> (i16, i16) {
1076 let (width, height) = self.calculate_dimensions();
1077 self.state
1078 .animation
1079 .current_offset(&self.config.animation, width, height)
1080 }
1081
1082 pub fn animation_opacity(&self) -> f64 {
1084 self.state.animation.current_opacity(&self.config.animation)
1085 }
1086
1087 pub fn remaining_time(&self) -> Option<Duration> {
1091 self.config.duration.map(|d| {
1092 let wall_elapsed = self.state.created_at.elapsed();
1093 let mut paused = self.state.total_paused;
1094 if self.state.timer_paused
1095 && let Some(pause_start) = self.state.pause_started
1096 {
1097 paused += pause_start.elapsed();
1098 }
1099 let effective_elapsed = wall_elapsed.saturating_sub(paused);
1100 d.saturating_sub(effective_elapsed)
1101 })
1102 }
1103
1104 pub fn handle_key(&mut self, key: KeyEvent) -> ToastEvent {
1113 if !self.is_visible() {
1114 return ToastEvent::None;
1115 }
1116
1117 match key {
1118 KeyEvent::Esc => {
1119 if self.has_focus() {
1120 self.clear_focus();
1121 ToastEvent::None
1122 } else if self.config.dismissable {
1123 self.dismiss();
1124 ToastEvent::Dismissed
1125 } else {
1126 ToastEvent::None
1127 }
1128 }
1129 KeyEvent::Tab => {
1130 if self.actions.is_empty() {
1131 return ToastEvent::None;
1132 }
1133 let next = match self.state.focused_action {
1134 None => 0,
1135 Some(i) => (i + 1) % self.actions.len(),
1136 };
1137 self.state.focused_action = Some(next);
1138 self.pause_timer();
1139 ToastEvent::FocusChanged
1140 }
1141 KeyEvent::Enter => {
1142 if let Some(idx) = self.state.focused_action
1143 && let Some(action) = self.actions.get(idx)
1144 {
1145 let id = action.id.clone();
1146 self.dismiss();
1147 return ToastEvent::Action(id);
1148 }
1149 ToastEvent::None
1150 }
1151 _ => ToastEvent::None,
1152 }
1153 }
1154
1155 pub fn pause_timer(&mut self) {
1157 if !self.state.timer_paused {
1158 self.state.timer_paused = true;
1159 self.state.pause_started = Some(Instant::now());
1160 }
1161 }
1162
1163 pub fn resume_timer(&mut self) {
1165 if self.state.timer_paused {
1166 if let Some(pause_start) = self.state.pause_started.take() {
1167 self.state.total_paused += pause_start.elapsed();
1168 }
1169 self.state.timer_paused = false;
1170 }
1171 }
1172
1173 pub fn clear_focus(&mut self) {
1175 self.state.focused_action = None;
1176 self.resume_timer();
1177 }
1178
1179 pub fn has_focus(&self) -> bool {
1181 self.state.focused_action.is_some()
1182 }
1183
1184 pub fn focused_action(&self) -> Option<&ToastAction> {
1186 self.state
1187 .focused_action
1188 .and_then(|idx| self.actions.get(idx))
1189 }
1190
1191 pub fn calculate_dimensions(&self) -> (u16, u16) {
1193 let max_width = self.config.max_width as usize;
1194
1195 let icon_width = self
1197 .content
1198 .icon
1199 .map(|icon| {
1200 let mut buf = [0u8; 4];
1201 let s = icon.as_char().encode_utf8(&mut buf);
1202 display_width(s) + 1
1203 })
1204 .unwrap_or(0); let message_width = display_width(self.content.message.as_str());
1206 let title_width = self
1207 .content
1208 .title
1209 .as_ref()
1210 .map(|t| display_width(t.as_str()))
1211 .unwrap_or(0);
1212
1213 let mut content_width = (icon_width + message_width).max(title_width);
1215
1216 if !self.actions.is_empty() {
1218 let actions_width: usize = self
1219 .actions
1220 .iter()
1221 .map(|a| a.display_width())
1222 .sum::<usize>()
1223 + self.actions.len().saturating_sub(1); content_width = content_width.max(actions_width);
1225 }
1226
1227 let total_width = content_width.saturating_add(4).min(max_width);
1229
1230 let has_title = self.content.title.is_some();
1232 let has_actions = !self.actions.is_empty();
1233 let height = 3 + u16::from(has_title) + u16::from(has_actions);
1234
1235 (total_width as u16, height)
1236 }
1237}
1238
1239impl Widget for Toast {
1240 fn render(&self, area: Rect, frame: &mut Frame) {
1241 #[cfg(feature = "tracing")]
1242 let _span = tracing::debug_span!(
1243 "widget_render",
1244 widget = "Toast",
1245 x = area.x,
1246 y = area.y,
1247 w = area.width,
1248 h = area.height
1249 )
1250 .entered();
1251
1252 if area.is_empty() || !self.is_visible() {
1253 return;
1254 }
1255
1256 let deg = frame.buffer.degradation;
1257
1258 let (content_width, content_height) = self.calculate_dimensions();
1260 let width = area.width.min(content_width);
1261 let height = area.height.min(content_height);
1262
1263 if width < 3 || height < 3 {
1264 return; }
1266
1267 let render_area = Rect::new(area.x, area.y, width, height);
1268
1269 if deg.apply_styling() {
1271 set_style_area(&mut frame.buffer, render_area, self.style);
1272 }
1273
1274 let use_unicode = deg.apply_styling();
1276 let (tl, tr, bl, br, h, v) = if use_unicode {
1277 (
1278 '\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{2500}', '\u{2502}',
1279 )
1280 } else {
1281 ('+', '+', '+', '+', '-', '|')
1282 };
1283
1284 if let Some(cell) = frame.buffer.get_mut(render_area.x, render_area.y) {
1286 *cell = Cell::from_char(tl);
1287 if deg.apply_styling() {
1288 crate::apply_style(cell, self.style);
1289 }
1290 }
1291 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1292 if let Some(cell) = frame.buffer.get_mut(x, render_area.y) {
1293 *cell = Cell::from_char(h);
1294 if deg.apply_styling() {
1295 crate::apply_style(cell, self.style);
1296 }
1297 }
1298 }
1299 if let Some(cell) = frame
1300 .buffer
1301 .get_mut(render_area.right().saturating_sub(1), render_area.y)
1302 {
1303 *cell = Cell::from_char(tr);
1304 if deg.apply_styling() {
1305 crate::apply_style(cell, self.style);
1306 }
1307 }
1308
1309 let bottom_y = render_area.bottom().saturating_sub(1);
1311 if let Some(cell) = frame.buffer.get_mut(render_area.x, bottom_y) {
1312 *cell = Cell::from_char(bl);
1313 if deg.apply_styling() {
1314 crate::apply_style(cell, self.style);
1315 }
1316 }
1317 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1318 if let Some(cell) = frame.buffer.get_mut(x, bottom_y) {
1319 *cell = Cell::from_char(h);
1320 if deg.apply_styling() {
1321 crate::apply_style(cell, self.style);
1322 }
1323 }
1324 }
1325 if let Some(cell) = frame
1326 .buffer
1327 .get_mut(render_area.right().saturating_sub(1), bottom_y)
1328 {
1329 *cell = Cell::from_char(br);
1330 if deg.apply_styling() {
1331 crate::apply_style(cell, self.style);
1332 }
1333 }
1334
1335 for y in (render_area.y + 1)..bottom_y {
1337 if let Some(cell) = frame.buffer.get_mut(render_area.x, y) {
1338 *cell = Cell::from_char(v);
1339 if deg.apply_styling() {
1340 crate::apply_style(cell, self.style);
1341 }
1342 }
1343 if let Some(cell) = frame
1344 .buffer
1345 .get_mut(render_area.right().saturating_sub(1), y)
1346 {
1347 *cell = Cell::from_char(v);
1348 if deg.apply_styling() {
1349 crate::apply_style(cell, self.style);
1350 }
1351 }
1352 }
1353
1354 let content_x = render_area.x + 1; let content_width = width.saturating_sub(2); let mut content_y = render_area.y + 1;
1358
1359 if let Some(ref title) = self.content.title {
1361 let title_style = if deg.apply_styling() {
1362 self.title_style.merge(&self.style)
1363 } else {
1364 Style::default()
1365 };
1366
1367 let title_style = if deg.apply_styling() {
1368 title_style
1369 } else {
1370 Style::default()
1371 };
1372 crate::draw_text_span(
1373 frame,
1374 content_x,
1375 content_y,
1376 title,
1377 title_style,
1378 content_x + content_width,
1379 );
1380 content_y += 1;
1381 }
1382
1383 let mut msg_x = content_x;
1385
1386 if let Some(icon) = self.content.icon {
1387 let icon_char = if use_unicode {
1388 icon.as_char()
1389 } else {
1390 icon.as_ascii()
1391 };
1392
1393 let icon_style = if deg.apply_styling() {
1394 self.icon_style.merge(&self.style)
1395 } else {
1396 Style::default()
1397 };
1398 let icon_str = icon_char.to_string();
1399 msg_x = crate::draw_text_span(
1400 frame,
1401 msg_x,
1402 content_y,
1403 &icon_str,
1404 icon_style,
1405 content_x + content_width,
1406 );
1407 msg_x = crate::draw_text_span(
1408 frame,
1409 msg_x,
1410 content_y,
1411 " ",
1412 Style::default(),
1413 content_x + content_width,
1414 );
1415 }
1416
1417 let msg_style = if deg.apply_styling() {
1419 self.style
1420 } else {
1421 Style::default()
1422 };
1423 crate::draw_text_span(
1424 frame,
1425 msg_x,
1426 content_y,
1427 &self.content.message,
1428 msg_style,
1429 content_x + content_width,
1430 );
1431
1432 if !self.actions.is_empty() {
1434 content_y += 1;
1435 let mut btn_x = content_x;
1436
1437 for (idx, action) in self.actions.iter().enumerate() {
1438 let is_focused = self.state.focused_action == Some(idx);
1439 let btn_style = if is_focused && deg.apply_styling() {
1440 self.action_focus_style.merge(&self.style)
1441 } else if deg.apply_styling() {
1442 self.action_style.merge(&self.style)
1443 } else {
1444 Style::default()
1445 };
1446
1447 let max_x = content_x + content_width;
1448 let label = format!("[{}]", action.label);
1449 btn_x = crate::draw_text_span(frame, btn_x, content_y, &label, btn_style, max_x);
1450
1451 if idx + 1 < self.actions.len() {
1453 btn_x = crate::draw_text_span(
1454 frame,
1455 btn_x,
1456 content_y,
1457 " ",
1458 Style::default(),
1459 max_x,
1460 );
1461 }
1462 }
1463 }
1464 }
1465
1466 fn is_essential(&self) -> bool {
1467 false
1469 }
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474 use super::*;
1475 use ftui_render::grapheme_pool::GraphemePool;
1476
1477 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
1478 frame
1479 .buffer
1480 .get(x, y)
1481 .copied()
1482 .unwrap_or_else(|| panic!("test cell should exist at ({x},{y})"))
1483 }
1484
1485 fn focused_action_id(toast: &Toast) -> &str {
1486 toast
1487 .focused_action()
1488 .expect("focused action should exist")
1489 .id
1490 .as_str()
1491 }
1492
1493 fn unwrap_remaining(remaining: Option<Duration>) -> Duration {
1494 remaining.expect("remaining duration should exist")
1495 }
1496
1497 #[test]
1498 fn test_toast_new() {
1499 let toast = Toast::new("Hello");
1500 assert_eq!(toast.content.message, "Hello");
1501 assert!(toast.content.icon.is_none());
1502 assert!(toast.content.title.is_none());
1503 assert!(toast.is_visible());
1504 }
1505
1506 #[test]
1507 fn test_toast_builder() {
1508 let toast = Toast::new("Test message")
1509 .icon(ToastIcon::Success)
1510 .title("Success")
1511 .position(ToastPosition::BottomRight)
1512 .duration(Duration::from_secs(10))
1513 .max_width(60);
1514
1515 assert_eq!(toast.content.message, "Test message");
1516 assert_eq!(toast.content.icon, Some(ToastIcon::Success));
1517 assert_eq!(toast.content.title, Some("Success".to_string()));
1518 assert_eq!(toast.config.position, ToastPosition::BottomRight);
1519 assert_eq!(toast.config.duration, Some(Duration::from_secs(10)));
1520 assert_eq!(toast.config.max_width, 60);
1521 }
1522
1523 #[test]
1524 fn test_toast_persistent() {
1525 let toast = Toast::new("Persistent").persistent();
1526 assert!(toast.config.duration.is_none());
1527 assert!(!toast.is_expired());
1528 }
1529
1530 #[test]
1531 fn test_toast_dismiss() {
1532 let mut toast = Toast::new("Dismissable");
1533 assert!(toast.is_visible());
1534 toast.dismiss();
1535 assert!(!toast.is_visible());
1536 assert!(toast.state.dismissed);
1537 }
1538
1539 #[test]
1540 fn test_toast_position_calculate() {
1541 let terminal_width = 80;
1542 let terminal_height = 24;
1543 let toast_width = 30;
1544 let toast_height = 3;
1545 let margin = 1;
1546
1547 let (x, y) = ToastPosition::TopLeft.calculate_position(
1549 terminal_width,
1550 terminal_height,
1551 toast_width,
1552 toast_height,
1553 margin,
1554 );
1555 assert_eq!(x, 1);
1556 assert_eq!(y, 1);
1557
1558 let (x, y) = ToastPosition::TopRight.calculate_position(
1560 terminal_width,
1561 terminal_height,
1562 toast_width,
1563 toast_height,
1564 margin,
1565 );
1566 assert_eq!(x, 80 - 30 - 1); assert_eq!(y, 1);
1568
1569 let (x, y) = ToastPosition::BottomRight.calculate_position(
1571 terminal_width,
1572 terminal_height,
1573 toast_width,
1574 toast_height,
1575 margin,
1576 );
1577 assert_eq!(x, 49);
1578 assert_eq!(y, 24 - 3 - 1); let (x, y) = ToastPosition::TopCenter.calculate_position(
1582 terminal_width,
1583 terminal_height,
1584 toast_width,
1585 toast_height,
1586 margin,
1587 );
1588 assert_eq!(x, (80 - 30) / 2); assert_eq!(y, 1);
1590 }
1591
1592 #[test]
1593 fn test_toast_icon_chars() {
1594 assert_eq!(ToastIcon::Success.as_char(), '\u{2713}');
1595 assert_eq!(ToastIcon::Error.as_char(), '\u{2717}');
1596 assert_eq!(ToastIcon::Warning.as_char(), '!');
1597 assert_eq!(ToastIcon::Info.as_char(), 'i');
1598 assert_eq!(ToastIcon::Custom('*').as_char(), '*');
1599
1600 assert_eq!(ToastIcon::Success.as_ascii(), '+');
1602 assert_eq!(ToastIcon::Error.as_ascii(), 'x');
1603 }
1604
1605 #[test]
1606 fn test_toast_dimensions() {
1607 let toast = Toast::new("Short");
1608 let (w, h) = toast.calculate_dimensions();
1609 assert_eq!(w, 9);
1611 assert_eq!(h, 3); let toast_with_title = Toast::new("Message").title("Title");
1614 let (_w, h) = toast_with_title.calculate_dimensions();
1615 assert_eq!(h, 4); }
1617
1618 #[test]
1619 fn test_toast_dimensions_with_icon() {
1620 let toast = Toast::new("Message").icon(ToastIcon::Success);
1621 let (w, _h) = toast.calculate_dimensions();
1622 let mut buf = [0u8; 4];
1623 let icon = ToastIcon::Success.as_char().encode_utf8(&mut buf);
1624 let expected = display_width(icon) + 1 + display_width("Message") + 4;
1625 assert_eq!(w, expected as u16);
1626 }
1627
1628 #[test]
1629 fn test_toast_dimensions_max_width() {
1630 let toast = Toast::new("This is a very long message that exceeds max width").max_width(20);
1631 let (w, _h) = toast.calculate_dimensions();
1632 assert!(w <= 20);
1633 }
1634
1635 #[test]
1636 fn test_toast_render_basic() {
1637 let toast = Toast::new("Hello");
1638 let area = Rect::new(0, 0, 15, 5);
1639 let mut pool = GraphemePool::new();
1640 let mut frame = Frame::new(15, 5, &mut pool);
1641 toast.render(area, &mut frame);
1642
1643 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); assert!(frame.buffer.get(1, 1).is_some()); }
1647
1648 #[test]
1649 fn test_toast_render_with_icon() {
1650 let toast = Toast::new("OK").icon(ToastIcon::Success);
1651 let area = Rect::new(0, 0, 10, 5);
1652 let mut pool = GraphemePool::new();
1653 let mut frame = Frame::new(10, 5, &mut pool);
1654 toast.render(area, &mut frame);
1655
1656 let icon_cell = cell_at(&frame, 1, 1);
1658 if let Some(ch) = icon_cell.content.as_char() {
1659 assert_eq!(ch, '\u{2713}'); } else if let Some(id) = icon_cell.content.grapheme_id() {
1661 assert_eq!(frame.pool.get(id), Some("\u{2713}"));
1662 } else {
1663 panic!("expected toast icon cell to contain ✓");
1664 }
1665 }
1666
1667 #[test]
1668 fn test_toast_render_with_title() {
1669 let toast = Toast::new("Body").title("Head");
1670 let area = Rect::new(0, 0, 15, 6);
1671 let mut pool = GraphemePool::new();
1672 let mut frame = Frame::new(15, 6, &mut pool);
1673 toast.render(area, &mut frame);
1674
1675 let title_cell = cell_at(&frame, 1, 1);
1677 assert_eq!(title_cell.content.as_char(), Some('H'));
1678 }
1679
1680 #[test]
1681 fn test_toast_render_zero_area() {
1682 let toast = Toast::new("Test");
1683 let area = Rect::new(0, 0, 0, 0);
1684 let mut pool = GraphemePool::new();
1685 let mut frame = Frame::new(1, 1, &mut pool);
1686 toast.render(area, &mut frame); }
1688
1689 #[test]
1690 fn test_toast_render_small_area() {
1691 let toast = Toast::new("Test");
1692 let area = Rect::new(0, 0, 2, 2);
1693 let mut pool = GraphemePool::new();
1694 let mut frame = Frame::new(2, 2, &mut pool);
1695 toast.render(area, &mut frame); }
1697
1698 #[test]
1699 fn test_toast_not_visible_when_dismissed() {
1700 let mut toast = Toast::new("Test");
1701 toast.dismiss();
1702 let area = Rect::new(0, 0, 20, 5);
1703 let mut pool = GraphemePool::new();
1704 let mut frame = Frame::new(20, 5, &mut pool);
1705
1706 let original = cell_at(&frame, 0, 0).content.as_char();
1708
1709 toast.render(area, &mut frame);
1710
1711 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), original);
1713 }
1714
1715 #[test]
1716 fn test_toast_is_not_essential() {
1717 let toast = Toast::new("Test");
1718 assert!(!toast.is_essential());
1719 }
1720
1721 #[test]
1722 fn test_toast_id_uniqueness() {
1723 let toast1 = Toast::new("A");
1724 let toast2 = Toast::new("B");
1725 assert_ne!(toast1.id, toast2.id);
1726 }
1727
1728 #[test]
1729 fn test_toast_style_variants() {
1730 let success = Toast::new("OK").style_variant(ToastStyle::Success);
1731 let error = Toast::new("Fail").style_variant(ToastStyle::Error);
1732 let warning = Toast::new("Warn").style_variant(ToastStyle::Warning);
1733 let info = Toast::new("Info").style_variant(ToastStyle::Info);
1734 let neutral = Toast::new("Neutral").style_variant(ToastStyle::Neutral);
1735
1736 assert_eq!(success.config.style_variant, ToastStyle::Success);
1737 assert_eq!(error.config.style_variant, ToastStyle::Error);
1738 assert_eq!(warning.config.style_variant, ToastStyle::Warning);
1739 assert_eq!(info.config.style_variant, ToastStyle::Info);
1740 assert_eq!(neutral.config.style_variant, ToastStyle::Neutral);
1741 }
1742
1743 #[test]
1744 fn test_toast_content_builder() {
1745 let content = ToastContent::new("Message")
1746 .with_icon(ToastIcon::Warning)
1747 .with_title("Alert");
1748
1749 assert_eq!(content.message, "Message");
1750 assert_eq!(content.icon, Some(ToastIcon::Warning));
1751 assert_eq!(content.title, Some("Alert".to_string()));
1752 }
1753
1754 #[test]
1757 fn test_animation_phase_default() {
1758 let toast = Toast::new("Test");
1759 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
1760 }
1761
1762 #[test]
1763 fn test_animation_phase_reduced_motion() {
1764 let toast = Toast::new("Test").reduced_motion(true);
1765 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1766 assert!(toast.state.animation.reduced_motion);
1767 }
1768
1769 #[test]
1770 fn test_animation_no_animation() {
1771 let toast = Toast::new("Test").no_animation();
1772 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1773 assert!(toast.config.animation.is_disabled());
1774 }
1775
1776 #[test]
1777 fn test_entrance_animation_builder() {
1778 let toast = Toast::new("Test")
1779 .entrance_animation(ToastEntranceAnimation::SlideFromTop)
1780 .entrance_duration(Duration::from_millis(300))
1781 .entrance_easing(ToastEasing::Bounce);
1782
1783 assert_eq!(
1784 toast.config.animation.entrance,
1785 ToastEntranceAnimation::SlideFromTop
1786 );
1787 assert_eq!(
1788 toast.config.animation.entrance_duration,
1789 Duration::from_millis(300)
1790 );
1791 assert_eq!(toast.config.animation.entrance_easing, ToastEasing::Bounce);
1792 }
1793
1794 #[test]
1795 fn test_exit_animation_builder() {
1796 let toast = Toast::new("Test")
1797 .exit_animation(ToastExitAnimation::SlideOut)
1798 .exit_duration(Duration::from_millis(100))
1799 .exit_easing(ToastEasing::EaseInOut);
1800
1801 assert_eq!(toast.config.animation.exit, ToastExitAnimation::SlideOut);
1802 assert_eq!(
1803 toast.config.animation.exit_duration,
1804 Duration::from_millis(100)
1805 );
1806 assert_eq!(toast.config.animation.exit_easing, ToastEasing::EaseInOut);
1807 }
1808
1809 #[test]
1810 fn test_entrance_animation_offsets() {
1811 let width = 30u16;
1812 let height = 5u16;
1813
1814 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1816 assert_eq!(dx, 0);
1817 assert_eq!(dy, -(height as i16));
1818
1819 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(0.0, width, height);
1821 assert_eq!(dx, 0);
1822 assert_eq!(dy, -(height as i16));
1823
1824 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1826 assert_eq!(dx, 0);
1827 assert_eq!(dy, 0);
1828
1829 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.initial_offset(width, height);
1831 assert_eq!(dx, width as i16);
1832 assert_eq!(dy, 0);
1833 }
1834
1835 #[test]
1836 fn test_exit_animation_offsets() {
1837 let width = 30u16;
1838 let height = 5u16;
1839 let entrance = ToastEntranceAnimation::SlideFromRight;
1840
1841 let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1843 assert_eq!(dx, -(width as i16)); assert_eq!(dy, 0);
1845
1846 let (dx, dy) =
1848 ToastExitAnimation::SlideOut.offset_at_progress(0.0, width, height, entrance);
1849 assert_eq!(dx, 0);
1850 assert_eq!(dy, 0);
1851
1852 let (dx, dy) =
1854 ToastExitAnimation::SlideOut.offset_at_progress(1.0, width, height, entrance);
1855 assert_eq!(dx, -(width as i16));
1856 assert_eq!(dy, 0);
1857 }
1858
1859 #[test]
1860 fn test_easing_apply() {
1861 assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
1863
1864 assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
1866
1867 assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
1869
1870 for easing in [
1872 ToastEasing::Linear,
1873 ToastEasing::EaseIn,
1874 ToastEasing::EaseOut,
1875 ToastEasing::EaseInOut,
1876 ToastEasing::Bounce,
1877 ] {
1878 assert!((easing.apply(0.0) - 0.0).abs() < 0.001, "{:?} at 0", easing);
1879 assert!((easing.apply(1.0) - 1.0).abs() < 0.001, "{:?} at 1", easing);
1880 }
1881 }
1882
1883 #[test]
1884 fn test_animation_state_progress() {
1885 let state = ToastAnimationState::new();
1886 let progress = state.progress(Duration::from_millis(200));
1888 assert!(
1889 progress < 0.1,
1890 "Progress should be small immediately after creation"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_animation_state_zero_duration() {
1896 let state = ToastAnimationState::new();
1897 let progress = state.progress(Duration::ZERO);
1899 assert_eq!(progress, 1.0);
1900 }
1901
1902 #[test]
1903 fn test_dismiss_starts_exit_animation() {
1904 let mut toast = Toast::new("Test").no_animation();
1905 toast.state.animation.phase = ToastAnimationPhase::Visible;
1907 toast.state.animation.reduced_motion = false;
1908
1909 toast.dismiss();
1910
1911 assert!(toast.state.dismissed);
1912 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Exiting);
1913 }
1914
1915 #[test]
1916 fn test_dismiss_immediately() {
1917 let mut toast = Toast::new("Test");
1918 toast.dismiss_immediately();
1919
1920 assert!(toast.state.dismissed);
1921 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Hidden);
1922 assert!(!toast.is_visible());
1923 }
1924
1925 #[test]
1926 fn test_is_animating() {
1927 let toast = Toast::new("Test");
1928 assert!(toast.is_animating()); let toast_visible = Toast::new("Test").no_animation();
1931 assert!(!toast_visible.is_animating()); }
1933
1934 #[test]
1935 fn test_animation_opacity_fade_in() {
1936 let config = ToastAnimationConfig {
1937 entrance: ToastEntranceAnimation::FadeIn,
1938 exit: ToastExitAnimation::FadeOut,
1939 entrance_duration: Duration::from_millis(200),
1940 exit_duration: Duration::from_millis(150),
1941 entrance_easing: ToastEasing::Linear,
1942 exit_easing: ToastEasing::Linear,
1943 respect_reduced_motion: false,
1944 };
1945
1946 let mut state = ToastAnimationState::new();
1948 let opacity = state.current_opacity(&config);
1949 assert!(opacity < 0.1, "Should be low opacity at start");
1950
1951 state.phase = ToastAnimationPhase::Visible;
1953 let opacity = state.current_opacity(&config);
1954 assert!((opacity - 1.0).abs() < 0.001);
1955 }
1956
1957 #[test]
1958 fn test_animation_config_default() {
1959 let config = ToastAnimationConfig::default();
1960
1961 assert_eq!(config.entrance, ToastEntranceAnimation::SlideFromRight);
1962 assert_eq!(config.exit, ToastExitAnimation::FadeOut);
1963 assert_eq!(config.entrance_duration, Duration::from_millis(200));
1964 assert_eq!(config.exit_duration, Duration::from_millis(150));
1965 assert!(config.respect_reduced_motion);
1966 }
1967
1968 #[test]
1969 fn test_animation_affects_position() {
1970 assert!(ToastEntranceAnimation::SlideFromTop.affects_position());
1971 assert!(ToastEntranceAnimation::SlideFromRight.affects_position());
1972 assert!(!ToastEntranceAnimation::FadeIn.affects_position());
1973 assert!(!ToastEntranceAnimation::None.affects_position());
1974
1975 assert!(ToastExitAnimation::SlideOut.affects_position());
1976 assert!(ToastExitAnimation::SlideToLeft.affects_position());
1977 assert!(!ToastExitAnimation::FadeOut.affects_position());
1978 assert!(!ToastExitAnimation::None.affects_position());
1979 }
1980
1981 #[test]
1982 fn test_toast_animation_offset() {
1983 let toast = Toast::new("Test").entrance_animation(ToastEntranceAnimation::SlideFromRight);
1984 let (dx, dy) = toast.animation_offset();
1985 assert!(dx > 0, "Should have positive x offset at start");
1987 assert_eq!(dy, 0);
1988 }
1989
1990 #[test]
1993 fn action_builder_single() {
1994 let toast = Toast::new("msg").action(ToastAction::new("Retry", "retry"));
1995 assert_eq!(toast.actions.len(), 1);
1996 assert_eq!(toast.actions[0].label, "Retry");
1997 assert_eq!(toast.actions[0].id, "retry");
1998 }
1999
2000 #[test]
2001 fn action_builder_multiple() {
2002 let toast = Toast::new("msg")
2003 .action(ToastAction::new("Ack", "ack"))
2004 .action(ToastAction::new("Snooze", "snooze"));
2005 assert_eq!(toast.actions.len(), 2);
2006 }
2007
2008 #[test]
2009 fn action_builder_vec() {
2010 let actions = vec![
2011 ToastAction::new("A", "a"),
2012 ToastAction::new("B", "b"),
2013 ToastAction::new("C", "c"),
2014 ];
2015 let toast = Toast::new("msg").actions(actions);
2016 assert_eq!(toast.actions.len(), 3);
2017 }
2018
2019 #[test]
2020 fn action_display_width() {
2021 let a = ToastAction::new("OK", "ok");
2022 assert_eq!(a.display_width(), 4);
2024 }
2025
2026 #[test]
2027 fn handle_key_esc_dismisses() {
2028 let mut toast = Toast::new("msg").no_animation();
2029 let result = toast.handle_key(KeyEvent::Esc);
2030 assert_eq!(result, ToastEvent::Dismissed);
2031 }
2032
2033 #[test]
2034 fn handle_key_esc_clears_focus_first() {
2035 let mut toast = Toast::new("msg")
2036 .action(ToastAction::new("A", "a"))
2037 .no_animation();
2038 toast.handle_key(KeyEvent::Tab);
2040 assert!(toast.has_focus());
2041 let result = toast.handle_key(KeyEvent::Esc);
2043 assert_eq!(result, ToastEvent::None);
2044 assert!(!toast.has_focus());
2045 }
2046
2047 #[test]
2048 fn handle_key_tab_cycles_focus() {
2049 let mut toast = Toast::new("msg")
2050 .action(ToastAction::new("A", "a"))
2051 .action(ToastAction::new("B", "b"))
2052 .no_animation();
2053
2054 let r1 = toast.handle_key(KeyEvent::Tab);
2055 assert_eq!(r1, ToastEvent::FocusChanged);
2056 assert_eq!(toast.state.focused_action, Some(0));
2057
2058 let r2 = toast.handle_key(KeyEvent::Tab);
2059 assert_eq!(r2, ToastEvent::FocusChanged);
2060 assert_eq!(toast.state.focused_action, Some(1));
2061
2062 let r3 = toast.handle_key(KeyEvent::Tab);
2064 assert_eq!(r3, ToastEvent::FocusChanged);
2065 assert_eq!(toast.state.focused_action, Some(0));
2066 }
2067
2068 #[test]
2069 fn handle_key_tab_no_actions_is_noop() {
2070 let mut toast = Toast::new("msg").no_animation();
2071 let result = toast.handle_key(KeyEvent::Tab);
2072 assert_eq!(result, ToastEvent::None);
2073 }
2074
2075 #[test]
2076 fn handle_key_enter_invokes_action() {
2077 let mut toast = Toast::new("msg")
2078 .action(ToastAction::new("Retry", "retry"))
2079 .no_animation();
2080 toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2082 assert_eq!(result, ToastEvent::Action("retry".into()));
2083 }
2084
2085 #[test]
2086 fn handle_key_enter_no_focus_is_noop() {
2087 let mut toast = Toast::new("msg")
2088 .action(ToastAction::new("A", "a"))
2089 .no_animation();
2090 let result = toast.handle_key(KeyEvent::Enter);
2091 assert_eq!(result, ToastEvent::None);
2092 }
2093
2094 #[test]
2095 fn handle_key_other_is_noop() {
2096 let mut toast = Toast::new("msg").no_animation();
2097 let result = toast.handle_key(KeyEvent::Other);
2098 assert_eq!(result, ToastEvent::None);
2099 }
2100
2101 #[test]
2102 fn handle_key_dismissed_toast_is_noop() {
2103 let mut toast = Toast::new("msg").no_animation();
2104 toast.state.dismissed = true;
2105 let result = toast.handle_key(KeyEvent::Esc);
2106 assert_eq!(result, ToastEvent::None);
2107 }
2108
2109 #[test]
2110 fn pause_timer_sets_flag() {
2111 let mut toast = Toast::new("msg").no_animation();
2112 toast.pause_timer();
2113 assert!(toast.state.timer_paused);
2114 assert!(toast.state.pause_started.is_some());
2115 }
2116
2117 #[test]
2118 fn resume_timer_accumulates_paused() {
2119 let mut toast = Toast::new("msg").no_animation();
2120 toast.pause_timer();
2121 std::thread::sleep(Duration::from_millis(10));
2122 toast.resume_timer();
2123 assert!(!toast.state.timer_paused);
2124 assert!(toast.state.total_paused >= Duration::from_millis(5));
2125 }
2126
2127 #[test]
2128 fn pause_resume_idempotent() {
2129 let mut toast = Toast::new("msg").no_animation();
2130 toast.pause_timer();
2132 toast.pause_timer();
2133 assert!(toast.state.timer_paused);
2134 toast.resume_timer();
2136 toast.resume_timer();
2137 assert!(!toast.state.timer_paused);
2138 }
2139
2140 #[test]
2141 fn clear_focus_resumes_timer() {
2142 let mut toast = Toast::new("msg")
2143 .action(ToastAction::new("A", "a"))
2144 .no_animation();
2145 toast.handle_key(KeyEvent::Tab);
2146 assert!(toast.state.timer_paused);
2147 toast.clear_focus();
2148 assert!(!toast.has_focus());
2149 assert!(!toast.state.timer_paused);
2150 }
2151
2152 #[test]
2153 fn focused_action_returns_correct() {
2154 let mut toast = Toast::new("msg")
2155 .action(ToastAction::new("X", "x"))
2156 .action(ToastAction::new("Y", "y"))
2157 .no_animation();
2158 assert!(toast.focused_action().is_none());
2159 toast.handle_key(KeyEvent::Tab);
2160 assert_eq!(focused_action_id(&toast), "x");
2161 toast.handle_key(KeyEvent::Tab);
2162 assert_eq!(focused_action_id(&toast), "y");
2163 }
2164
2165 #[test]
2166 fn is_expired_accounts_for_pause() {
2167 let mut toast = Toast::new("msg")
2168 .duration(Duration::from_millis(50))
2169 .no_animation();
2170 toast.pause_timer();
2171 std::thread::sleep(Duration::from_millis(60));
2173 assert!(
2174 !toast.is_expired(),
2175 "Should not expire while timer is paused"
2176 );
2177 toast.resume_timer();
2178 assert!(
2180 !toast.is_expired(),
2181 "Should not expire immediately after resume because paused time was subtracted"
2182 );
2183 }
2184
2185 #[test]
2186 fn dimensions_include_actions_row() {
2187 let toast = Toast::new("Hi")
2188 .action(ToastAction::new("OK", "ok"))
2189 .no_animation();
2190 let (_, h) = toast.calculate_dimensions();
2191 assert_eq!(h, 4);
2194 }
2195
2196 #[test]
2197 fn dimensions_with_title_and_actions() {
2198 let toast = Toast::new("Hi")
2199 .title("Title")
2200 .action(ToastAction::new("OK", "ok"))
2201 .no_animation();
2202 let (_, h) = toast.calculate_dimensions();
2203 assert_eq!(h, 5);
2205 }
2206
2207 #[test]
2208 fn dimensions_width_accounts_for_actions() {
2209 let toast = Toast::new("Hi")
2210 .action(ToastAction::new("LongButtonLabel", "lb"))
2211 .no_animation();
2212 let (w, _) = toast.calculate_dimensions();
2213 assert!(w >= 20);
2216 }
2217
2218 #[test]
2219 fn render_with_actions_does_not_panic() {
2220 let toast = Toast::new("Test")
2221 .action(ToastAction::new("OK", "ok"))
2222 .action(ToastAction::new("Cancel", "cancel"))
2223 .no_animation();
2224
2225 let mut pool = GraphemePool::new();
2226 let mut frame = Frame::new(60, 20, &mut pool);
2227 let area = Rect::new(0, 0, 40, 10);
2228 toast.render(area, &mut frame);
2229 }
2230
2231 #[test]
2232 fn render_focused_action_does_not_panic() {
2233 let mut toast = Toast::new("Test")
2234 .action(ToastAction::new("OK", "ok"))
2235 .no_animation();
2236 toast.handle_key(KeyEvent::Tab); let mut pool = GraphemePool::new();
2239 let mut frame = Frame::new(60, 20, &mut pool);
2240 let area = Rect::new(0, 0, 40, 10);
2241 toast.render(area, &mut frame);
2242 }
2243
2244 #[test]
2245 fn render_actions_tiny_area_does_not_panic() {
2246 let toast = Toast::new("X")
2247 .action(ToastAction::new("A", "a"))
2248 .no_animation();
2249
2250 let mut pool = GraphemePool::new();
2251 let mut frame = Frame::new(5, 3, &mut pool);
2252 let area = Rect::new(0, 0, 5, 3);
2253 toast.render(area, &mut frame);
2254 }
2255
2256 #[test]
2257 fn toast_action_styles() {
2258 let style = Style::new().bold();
2259 let focus_style = Style::new().italic();
2260 let toast = Toast::new("msg")
2261 .action(ToastAction::new("A", "a"))
2262 .with_action_style(style)
2263 .with_action_focus_style(focus_style);
2264 assert_eq!(toast.action_style, style);
2265 assert_eq!(toast.action_focus_style, focus_style);
2266 }
2267
2268 #[test]
2269 fn persistent_toast_not_expired_with_actions() {
2270 let toast = Toast::new("msg")
2271 .persistent()
2272 .action(ToastAction::new("Dismiss", "dismiss"))
2273 .no_animation();
2274 std::thread::sleep(Duration::from_millis(10));
2275 assert!(!toast.is_expired());
2276 }
2277
2278 #[test]
2279 fn action_invoke_second_button() {
2280 let mut toast = Toast::new("msg")
2281 .action(ToastAction::new("A", "a"))
2282 .action(ToastAction::new("B", "b"))
2283 .no_animation();
2284 toast.handle_key(KeyEvent::Tab); toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2287 assert_eq!(result, ToastEvent::Action("b".into()));
2288 }
2289
2290 #[test]
2291 fn remaining_time_with_pause() {
2292 let toast = Toast::new("msg")
2293 .duration(Duration::from_secs(10))
2294 .no_animation();
2295 let remaining = toast.remaining_time();
2296 assert!(remaining.is_some());
2297 let r = unwrap_remaining(remaining);
2298 assert!(r > Duration::from_secs(9));
2299 }
2300}