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 fn offset_from_dimension(value: u16) -> i16 {
191 i16::try_from(value).unwrap_or(i16::MAX)
192 }
193
194 pub fn initial_offset(self, toast_width: u16, toast_height: u16) -> (i16, i16) {
198 let width_offset = Self::offset_from_dimension(toast_width);
199 let height_offset = Self::offset_from_dimension(toast_height);
200 match self {
201 Self::SlideFromTop => (0, -height_offset),
202 Self::SlideFromRight => (width_offset, 0),
203 Self::SlideFromBottom => (0, height_offset),
204 Self::SlideFromLeft => (-width_offset, 0),
205 Self::FadeIn | Self::None => (0, 0),
206 }
207 }
208
209 pub fn offset_at_progress(
213 self,
214 progress: f64,
215 toast_width: u16,
216 toast_height: u16,
217 ) -> (i16, i16) {
218 let (dx, dy) = self.initial_offset(toast_width, toast_height);
219 let inv_progress = 1.0 - progress.clamp(0.0, 1.0);
220 (
221 (dx as f64 * inv_progress).round() as i16,
222 (dy as f64 * inv_progress).round() as i16,
223 )
224 }
225
226 pub fn affects_position(self) -> bool {
228 !matches!(self, Self::FadeIn | Self::None)
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum ToastExitAnimation {
237 #[default]
239 FadeOut,
240 SlideOut,
242 SlideToTop,
244 SlideToRight,
245 SlideToBottom,
246 SlideToLeft,
247 None,
249}
250
251impl ToastExitAnimation {
252 pub fn final_offset(
256 self,
257 toast_width: u16,
258 toast_height: u16,
259 entrance: ToastEntranceAnimation,
260 ) -> (i16, i16) {
261 let width_offset = ToastEntranceAnimation::offset_from_dimension(toast_width);
262 let height_offset = ToastEntranceAnimation::offset_from_dimension(toast_height);
263 match self {
264 Self::SlideOut => {
265 let (dx, dy) = entrance.initial_offset(toast_width, toast_height);
267 (-dx, -dy)
268 }
269 Self::SlideToTop => (0, -height_offset),
270 Self::SlideToRight => (width_offset, 0),
271 Self::SlideToBottom => (0, height_offset),
272 Self::SlideToLeft => (-width_offset, 0),
273 Self::FadeOut | Self::None => (0, 0),
274 }
275 }
276
277 pub fn offset_at_progress(
281 self,
282 progress: f64,
283 toast_width: u16,
284 toast_height: u16,
285 entrance: ToastEntranceAnimation,
286 ) -> (i16, i16) {
287 let (dx, dy) = self.final_offset(toast_width, toast_height, entrance);
288 let p = progress.clamp(0.0, 1.0);
289 (
290 (dx as f64 * p).round() as i16,
291 (dy as f64 * p).round() as i16,
292 )
293 }
294
295 pub fn affects_position(self) -> bool {
297 !matches!(self, Self::FadeOut | Self::None)
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Default)]
306pub enum ToastEasing {
307 Linear,
309 #[default]
311 EaseOut,
312 EaseIn,
314 EaseInOut,
316 Bounce,
318}
319
320impl ToastEasing {
321 pub fn apply(self, t: f64) -> f64 {
323 let t = t.clamp(0.0, 1.0);
324 match self {
325 Self::Linear => t,
326 Self::EaseOut => {
327 let inv = 1.0 - t;
328 1.0 - inv * inv * inv
329 }
330 Self::EaseIn => t * t * t,
331 Self::EaseInOut => {
332 if t < 0.5 {
333 4.0 * t * t * t
334 } else {
335 let inv = -2.0 * t + 2.0;
336 1.0 - inv * inv * inv / 2.0
337 }
338 }
339 Self::Bounce => {
340 let n1 = 7.5625;
341 let d1 = 2.75;
342 let mut t = t;
343 if t < 1.0 / d1 {
344 n1 * t * t
345 } else if t < 2.0 / d1 {
346 t -= 1.5 / d1;
347 n1 * t * t + 0.75
348 } else if t < 2.5 / d1 {
349 t -= 2.25 / d1;
350 n1 * t * t + 0.9375
351 } else {
352 t -= 2.625 / d1;
353 n1 * t * t + 0.984375
354 }
355 }
356 }
357 }
358}
359
360#[derive(Debug, Clone)]
362pub struct ToastAnimationConfig {
363 pub entrance: ToastEntranceAnimation,
365 pub exit: ToastExitAnimation,
367 pub entrance_duration: Duration,
369 pub exit_duration: Duration,
371 pub entrance_easing: ToastEasing,
373 pub exit_easing: ToastEasing,
375 pub respect_reduced_motion: bool,
377}
378
379impl Default for ToastAnimationConfig {
380 fn default() -> Self {
381 Self {
382 entrance: ToastEntranceAnimation::default(),
383 exit: ToastExitAnimation::default(),
384 entrance_duration: Duration::from_millis(200),
385 exit_duration: Duration::from_millis(150),
386 entrance_easing: ToastEasing::EaseOut,
387 exit_easing: ToastEasing::EaseIn,
388 respect_reduced_motion: true,
389 }
390 }
391}
392
393impl ToastAnimationConfig {
394 pub fn none() -> Self {
396 Self {
397 entrance: ToastEntranceAnimation::None,
398 exit: ToastExitAnimation::None,
399 entrance_duration: Duration::ZERO,
400 exit_duration: Duration::ZERO,
401 ..Default::default()
402 }
403 }
404
405 pub fn is_disabled(&self) -> bool {
407 matches!(self.entrance, ToastEntranceAnimation::None)
408 && matches!(self.exit, ToastExitAnimation::None)
409 }
410}
411
412#[derive(Debug, Clone)]
414pub struct ToastAnimationState {
415 pub phase: ToastAnimationPhase,
417 pub phase_started: Instant,
419 pub reduced_motion: bool,
421}
422
423impl Default for ToastAnimationState {
424 fn default() -> Self {
425 Self {
426 phase: ToastAnimationPhase::Entering,
427 phase_started: Instant::now(),
428 reduced_motion: false,
429 }
430 }
431}
432
433impl ToastAnimationState {
434 pub fn new() -> Self {
436 Self::default()
437 }
438
439 pub fn with_reduced_motion() -> Self {
441 Self {
442 phase: ToastAnimationPhase::Visible,
443 phase_started: Instant::now(),
444 reduced_motion: true,
445 }
446 }
447
448 pub fn progress(&self, phase_duration: Duration) -> f64 {
450 if phase_duration.is_zero() {
451 return 1.0;
452 }
453 let elapsed = self.phase_started.elapsed();
454 (elapsed.as_secs_f64() / phase_duration.as_secs_f64()).min(1.0)
455 }
456
457 pub fn transition_to(&mut self, phase: ToastAnimationPhase) {
459 self.phase = phase;
460 self.phase_started = Instant::now();
461 }
462
463 pub fn start_exit(&mut self) {
465 if self.reduced_motion {
466 self.transition_to(ToastAnimationPhase::Hidden);
467 } else {
468 self.transition_to(ToastAnimationPhase::Exiting);
469 }
470 }
471
472 pub fn is_complete(&self) -> bool {
474 self.phase == ToastAnimationPhase::Hidden
475 }
476
477 pub fn tick(&mut self, config: &ToastAnimationConfig) -> bool {
481 let prev_phase = self.phase;
482
483 match self.phase {
484 ToastAnimationPhase::Entering => {
485 let duration = if self.reduced_motion {
486 Duration::ZERO
487 } else {
488 config.entrance_duration
489 };
490 if self.progress(duration) >= 1.0 {
491 self.transition_to(ToastAnimationPhase::Visible);
492 }
493 }
494 ToastAnimationPhase::Exiting => {
495 let duration = if self.reduced_motion {
496 Duration::ZERO
497 } else {
498 config.exit_duration
499 };
500 if self.progress(duration) >= 1.0 {
501 self.transition_to(ToastAnimationPhase::Hidden);
502 }
503 }
504 ToastAnimationPhase::Visible | ToastAnimationPhase::Hidden => {}
505 }
506
507 self.phase != prev_phase
508 }
509
510 pub fn current_offset(
514 &self,
515 config: &ToastAnimationConfig,
516 toast_width: u16,
517 toast_height: u16,
518 ) -> (i16, i16) {
519 if self.reduced_motion {
520 return (0, 0);
521 }
522
523 match self.phase {
524 ToastAnimationPhase::Entering => {
525 let raw_progress = self.progress(config.entrance_duration);
526 let eased_progress = config.entrance_easing.apply(raw_progress);
527 config
528 .entrance
529 .offset_at_progress(eased_progress, toast_width, toast_height)
530 }
531 ToastAnimationPhase::Exiting => {
532 let raw_progress = self.progress(config.exit_duration);
533 let eased_progress = config.exit_easing.apply(raw_progress);
534 config.exit.offset_at_progress(
535 eased_progress,
536 toast_width,
537 toast_height,
538 config.entrance,
539 )
540 }
541 ToastAnimationPhase::Visible => (0, 0),
542 ToastAnimationPhase::Hidden => (0, 0),
543 }
544 }
545
546 pub fn current_opacity(&self, config: &ToastAnimationConfig) -> f64 {
550 if self.reduced_motion {
551 return if self.phase == ToastAnimationPhase::Hidden {
552 0.0
553 } else {
554 1.0
555 };
556 }
557
558 match self.phase {
559 ToastAnimationPhase::Entering => {
560 if matches!(config.entrance, ToastEntranceAnimation::FadeIn) {
561 let raw_progress = self.progress(config.entrance_duration);
562 config.entrance_easing.apply(raw_progress)
563 } else {
564 1.0
565 }
566 }
567 ToastAnimationPhase::Exiting => {
568 if matches!(config.exit, ToastExitAnimation::FadeOut) {
569 let raw_progress = self.progress(config.exit_duration);
570 1.0 - config.exit_easing.apply(raw_progress)
571 } else {
572 1.0
573 }
574 }
575 ToastAnimationPhase::Visible => 1.0,
576 ToastAnimationPhase::Hidden => 0.0,
577 }
578 }
579}
580
581#[derive(Debug, Clone)]
583pub struct ToastConfig {
584 pub position: ToastPosition,
586 pub duration: Option<Duration>,
588 pub duration_explicit: bool,
591 pub style_variant: ToastStyle,
593 pub max_width: u16,
595 pub margin: u16,
597 pub dismissable: bool,
599 pub animation: ToastAnimationConfig,
601}
602
603impl Default for ToastConfig {
604 fn default() -> Self {
605 Self {
606 position: ToastPosition::default(),
607 duration: Some(Duration::from_secs(5)),
608 duration_explicit: false,
609 style_variant: ToastStyle::default(),
610 max_width: 50,
611 margin: 1,
612 dismissable: true,
613 animation: ToastAnimationConfig::default(),
614 }
615 }
616}
617
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub enum KeyEvent {
625 Esc,
627 Tab,
629 Enter,
631 Other,
633}
634
635#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct ToastAction {
654 pub label: String,
656 pub id: String,
658}
659
660impl ToastAction {
661 pub fn new(label: impl Into<String>, id: impl Into<String>) -> Self {
667 let label = label.into();
668 let id = id.into();
669 debug_assert!(
670 !label.trim().is_empty(),
671 "ToastAction label must not be empty"
672 );
673 debug_assert!(!id.trim().is_empty(), "ToastAction id must not be empty");
674 Self { label, id }
675 }
676
677 pub fn display_width(&self) -> usize {
681 display_width(self.label.as_str()) + 2 }
683}
684
685#[derive(Debug, Clone, PartialEq, Eq)]
689pub enum ToastEvent {
690 None,
692 Dismissed,
694 Action(String),
696 FocusChanged,
698}
699
700#[derive(Debug, Clone)]
702pub struct ToastContent {
703 pub message: String,
705 pub icon: Option<ToastIcon>,
707 pub title: Option<String>,
709}
710
711impl ToastContent {
712 pub fn new(message: impl Into<String>) -> Self {
714 Self {
715 message: message.into(),
716 icon: None,
717 title: None,
718 }
719 }
720
721 #[must_use]
723 pub fn with_icon(mut self, icon: ToastIcon) -> Self {
724 self.icon = Some(icon);
725 self
726 }
727
728 #[must_use]
730 pub fn with_title(mut self, title: impl Into<String>) -> Self {
731 self.title = Some(title.into());
732 self
733 }
734}
735
736#[derive(Debug, Clone)]
738pub struct ToastState {
739 pub created_at: Instant,
741 pub dismissed: bool,
743 pub animation: ToastAnimationState,
745 pub focused_action: Option<usize>,
747 pub timer_paused: bool,
749 pub pause_started: Option<Instant>,
751 pub total_paused: Duration,
753}
754
755impl Default for ToastState {
756 fn default() -> Self {
757 Self {
758 created_at: Instant::now(),
759 dismissed: false,
760 animation: ToastAnimationState::default(),
761 focused_action: None,
762 timer_paused: false,
763 pause_started: None,
764 total_paused: Duration::ZERO,
765 }
766 }
767}
768
769impl ToastState {
770 pub fn with_reduced_motion() -> Self {
772 Self {
773 created_at: Instant::now(),
774 dismissed: false,
775 animation: ToastAnimationState::with_reduced_motion(),
776 focused_action: None,
777 timer_paused: false,
778 pause_started: None,
779 total_paused: Duration::ZERO,
780 }
781 }
782}
783
784#[derive(Debug, Clone)]
802pub struct Toast {
803 pub id: ToastId,
805 pub content: ToastContent,
807 pub config: ToastConfig,
809 pub state: ToastState,
811 pub actions: Vec<ToastAction>,
813 style: Style,
815 icon_style: Style,
817 title_style: Style,
819 action_style: Style,
821 action_focus_style: Style,
823}
824
825impl Toast {
826 pub fn new(message: impl Into<String>) -> Self {
828 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
829 let id = ToastId::new(NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
830
831 Self {
832 id,
833 content: ToastContent::new(message),
834 config: ToastConfig::default(),
835 state: ToastState::default(),
836 actions: Vec::new(),
837 style: Style::default(),
838 icon_style: Style::default(),
839 title_style: Style::default(),
840 action_style: Style::default(),
841 action_focus_style: Style::default(),
842 }
843 }
844
845 pub fn with_id(id: ToastId, message: impl Into<String>) -> Self {
847 Self {
848 id,
849 content: ToastContent::new(message),
850 config: ToastConfig::default(),
851 state: ToastState::default(),
852 actions: Vec::new(),
853 style: Style::default(),
854 icon_style: Style::default(),
855 title_style: Style::default(),
856 action_style: Style::default(),
857 action_focus_style: Style::default(),
858 }
859 }
860
861 #[must_use]
865 pub fn icon(mut self, icon: ToastIcon) -> Self {
866 self.content.icon = Some(icon);
867 self
868 }
869
870 #[must_use]
872 pub fn title(mut self, title: impl Into<String>) -> Self {
873 self.content.title = Some(title.into());
874 self
875 }
876
877 #[must_use]
879 pub fn position(mut self, position: ToastPosition) -> Self {
880 self.config.position = position;
881 self
882 }
883
884 #[must_use]
886 pub fn duration(mut self, duration: Duration) -> Self {
887 self.config.duration = Some(duration);
888 self.config.duration_explicit = true;
889 self
890 }
891
892 #[must_use]
894 pub fn persistent(mut self) -> Self {
895 self.config.duration = None;
896 self.config.duration_explicit = true;
897 self
898 }
899
900 #[must_use]
902 pub fn style_variant(mut self, variant: ToastStyle) -> Self {
903 self.config.style_variant = variant;
904 self
905 }
906
907 #[must_use]
909 pub fn max_width(mut self, width: u16) -> Self {
910 self.config.max_width = width;
911 self
912 }
913
914 #[must_use]
916 pub fn margin(mut self, margin: u16) -> Self {
917 self.config.margin = margin;
918 self
919 }
920
921 #[must_use]
923 pub fn dismissable(mut self, dismissable: bool) -> Self {
924 self.config.dismissable = dismissable;
925 self
926 }
927
928 #[must_use]
930 pub fn style(mut self, style: Style) -> Self {
931 self.style = style;
932 self
933 }
934
935 #[must_use]
937 pub fn with_icon_style(mut self, style: Style) -> Self {
938 self.icon_style = style;
939 self
940 }
941
942 #[must_use]
944 pub fn with_title_style(mut self, style: Style) -> Self {
945 self.title_style = style;
946 self
947 }
948
949 #[must_use]
953 pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
954 self.config.animation.entrance = animation;
955 self
956 }
957
958 #[must_use]
960 pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
961 self.config.animation.exit = animation;
962 self
963 }
964
965 #[must_use]
967 pub fn entrance_duration(mut self, duration: Duration) -> Self {
968 self.config.animation.entrance_duration = duration;
969 self
970 }
971
972 #[must_use]
974 pub fn exit_duration(mut self, duration: Duration) -> Self {
975 self.config.animation.exit_duration = duration;
976 self
977 }
978
979 #[must_use]
981 pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
982 self.config.animation.entrance_easing = easing;
983 self
984 }
985
986 #[must_use]
988 pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
989 self.config.animation.exit_easing = easing;
990 self
991 }
992
993 #[must_use]
997 pub fn action(mut self, action: ToastAction) -> Self {
998 self.actions.push(action);
999 self
1000 }
1001
1002 #[must_use]
1004 pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
1005 self.actions = actions;
1006 self
1007 }
1008
1009 #[must_use]
1011 pub fn with_action_style(mut self, style: Style) -> Self {
1012 self.action_style = style;
1013 self
1014 }
1015
1016 #[must_use]
1018 pub fn with_action_focus_style(mut self, style: Style) -> Self {
1019 self.action_focus_style = style;
1020 self
1021 }
1022
1023 #[must_use]
1025 pub fn no_animation(mut self) -> Self {
1026 self.config.animation = ToastAnimationConfig::none();
1027 self.state.animation = ToastAnimationState {
1028 phase: ToastAnimationPhase::Visible,
1029 phase_started: Instant::now(),
1030 reduced_motion: true,
1031 };
1032 self
1033 }
1034
1035 #[must_use]
1037 pub fn reduced_motion(mut self, enabled: bool) -> Self {
1038 self.config.animation.respect_reduced_motion = enabled;
1039 if enabled {
1040 self.state.animation = ToastAnimationState::with_reduced_motion();
1041 }
1042 self
1043 }
1044
1045 pub fn is_expired(&self) -> bool {
1051 if let Some(duration) = self.config.duration {
1052 let wall_elapsed = self.state.created_at.elapsed();
1053 let effective_elapsed = wall_elapsed.saturating_sub(self.paused_duration());
1054 effective_elapsed >= duration
1055 } else {
1056 false
1057 }
1058 }
1059
1060 #[inline]
1064 pub fn is_visible(&self) -> bool {
1065 self.state.animation.phase != ToastAnimationPhase::Hidden
1066 }
1067
1068 pub fn is_animating(&self) -> bool {
1070 matches!(
1071 self.state.animation.phase,
1072 ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1073 )
1074 }
1075
1076 pub fn dismiss(&mut self) {
1078 if !self.state.dismissed {
1079 self.state.dismissed = true;
1080 self.state.animation.start_exit();
1081 }
1082 }
1083
1084 pub fn dismiss_immediately(&mut self) {
1086 self.state.dismissed = true;
1087 self.state
1088 .animation
1089 .transition_to(ToastAnimationPhase::Hidden);
1090 }
1091
1092 pub fn tick_animation(&mut self) -> bool {
1096 self.state.animation.tick(&self.config.animation)
1097 }
1098
1099 pub fn animation_phase(&self) -> ToastAnimationPhase {
1101 self.state.animation.phase
1102 }
1103
1104 pub fn animation_offset(&self) -> (i16, i16) {
1108 let (width, height) = self.calculate_dimensions();
1109 self.state
1110 .animation
1111 .current_offset(&self.config.animation, width, height)
1112 }
1113
1114 pub fn animation_opacity(&self) -> f64 {
1116 self.state.animation.current_opacity(&self.config.animation)
1117 }
1118
1119 #[must_use = "use the remaining time (if any) for scheduling"]
1123 pub fn remaining_time(&self) -> Option<Duration> {
1124 self.config.duration.map(|d| {
1125 let wall_elapsed = self.state.created_at.elapsed();
1126 let effective_elapsed = wall_elapsed.saturating_sub(self.paused_duration());
1127 d.saturating_sub(effective_elapsed)
1128 })
1129 }
1130
1131 pub fn handle_key(&mut self, key: KeyEvent) -> ToastEvent {
1140 if !self.is_visible() || self.state.dismissed {
1141 return ToastEvent::None;
1142 }
1143
1144 match key {
1145 KeyEvent::Esc => {
1146 if self.has_focus() {
1147 self.clear_focus();
1148 ToastEvent::None
1149 } else if self.config.dismissable {
1150 self.dismiss();
1151 ToastEvent::Dismissed
1152 } else {
1153 ToastEvent::None
1154 }
1155 }
1156 KeyEvent::Tab => {
1157 if self.actions.is_empty() {
1158 return ToastEvent::None;
1159 }
1160 let next = match self.state.focused_action {
1161 None => 0,
1162 Some(i) => (i + 1) % self.actions.len(),
1163 };
1164 self.state.focused_action = Some(next);
1165 self.pause_timer();
1166 ToastEvent::FocusChanged
1167 }
1168 KeyEvent::Enter => {
1169 if let Some(idx) = self.state.focused_action
1170 && let Some(action) = self.actions.get(idx)
1171 {
1172 let id = action.id.clone();
1173 self.dismiss();
1174 return ToastEvent::Action(id);
1175 }
1176 ToastEvent::None
1177 }
1178 _ => ToastEvent::None,
1179 }
1180 }
1181
1182 pub fn pause_timer(&mut self) {
1184 if !self.state.timer_paused {
1185 self.state.timer_paused = true;
1186 self.state.pause_started = Some(Instant::now());
1187 }
1188 }
1189
1190 pub fn resume_timer(&mut self) {
1192 if self.state.timer_paused {
1193 if let Some(pause_start) = self.state.pause_started.take() {
1194 self.state.total_paused = self
1195 .state
1196 .total_paused
1197 .saturating_add(pause_start.elapsed());
1198 }
1199 self.state.timer_paused = false;
1200 }
1201 }
1202
1203 pub fn clear_focus(&mut self) {
1205 self.state.focused_action = None;
1206 self.resume_timer();
1207 }
1208
1209 pub fn has_focus(&self) -> bool {
1211 self.state.focused_action.is_some()
1212 }
1213
1214 #[must_use = "use the focused action (if any)"]
1216 pub fn focused_action(&self) -> Option<&ToastAction> {
1217 self.state
1218 .focused_action
1219 .and_then(|idx| self.actions.get(idx))
1220 }
1221
1222 fn paused_duration(&self) -> Duration {
1223 let mut paused = self.state.total_paused;
1224 if self.state.timer_paused
1225 && let Some(pause_start) = self.state.pause_started
1226 {
1227 paused = paused.saturating_add(pause_start.elapsed());
1228 }
1229 paused
1230 }
1231
1232 pub fn calculate_dimensions(&self) -> (u16, u16) {
1234 let max_width = self.config.max_width as usize;
1235
1236 let icon_width = self
1238 .content
1239 .icon
1240 .map(|icon| {
1241 let mut buf = [0u8; 4];
1242 let s = icon.as_char().encode_utf8(&mut buf);
1243 display_width(s) + 1
1244 })
1245 .unwrap_or(0); let message_width = display_width(self.content.message.as_str());
1247 let title_width = self
1248 .content
1249 .title
1250 .as_ref()
1251 .map(|t| display_width(t.as_str()))
1252 .unwrap_or(0);
1253
1254 let mut content_width = (icon_width + message_width).max(title_width);
1256
1257 if !self.actions.is_empty() {
1259 let actions_width: usize = self
1260 .actions
1261 .iter()
1262 .map(|a| a.display_width())
1263 .sum::<usize>()
1264 + self.actions.len().saturating_sub(1); content_width = content_width.max(actions_width);
1266 }
1267
1268 let total_width = content_width.saturating_add(4).min(max_width);
1270
1271 let has_title = self.content.title.is_some();
1273 let has_actions = !self.actions.is_empty();
1274 let height = 3 + u16::from(has_title) + u16::from(has_actions);
1275
1276 (total_width as u16, height)
1277 }
1278}
1279
1280impl Widget for Toast {
1281 fn render(&self, area: Rect, frame: &mut Frame) {
1282 #[cfg(feature = "tracing")]
1283 let _span = tracing::debug_span!(
1284 "widget_render",
1285 widget = "Toast",
1286 x = area.x,
1287 y = area.y,
1288 w = area.width,
1289 h = area.height
1290 )
1291 .entered();
1292
1293 if area.is_empty() {
1294 return;
1295 }
1296
1297 let (content_width, content_height) = self.calculate_dimensions();
1299 let width = area.width.min(content_width);
1300 let height = area.height.min(content_height);
1301
1302 if width < 3 || height < 3 {
1303 return; }
1305
1306 let render_area = Rect::new(area.x, area.y, width, height);
1307
1308 if !self.is_visible() {
1309 clear_text_area(frame, render_area, Style::default());
1310 return;
1311 }
1312
1313 let deg = frame.buffer.degradation;
1314 if !deg.render_content() {
1315 return;
1316 }
1317
1318 let base_style = if deg.apply_styling() {
1319 self.style
1320 } else {
1321 Style::default()
1322 };
1323 clear_text_area(frame, render_area, base_style);
1324
1325 let use_unicode = deg.use_unicode_borders();
1327 let (tl, tr, bl, br, h, v) = if use_unicode {
1328 (
1329 '\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{2500}', '\u{2502}',
1330 )
1331 } else {
1332 ('+', '+', '+', '+', '-', '|')
1333 };
1334
1335 let mut cell = Cell::from_char(tl);
1337 if deg.apply_styling() {
1338 crate::apply_style(&mut cell, self.style);
1339 }
1340 frame.buffer.set_fast(render_area.x, render_area.y, cell);
1341
1342 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1343 let mut cell = Cell::from_char(h);
1344 if deg.apply_styling() {
1345 crate::apply_style(&mut cell, self.style);
1346 }
1347 frame.buffer.set_fast(x, render_area.y, cell);
1348 }
1349
1350 let mut cell_tr = Cell::from_char(tr);
1351 if deg.apply_styling() {
1352 crate::apply_style(&mut cell_tr, self.style);
1353 }
1354 frame.buffer.set_fast(
1355 render_area.right().saturating_sub(1),
1356 render_area.y,
1357 cell_tr,
1358 );
1359
1360 let bottom_y = render_area.bottom().saturating_sub(1);
1362 let mut cell_bl = Cell::from_char(bl);
1363 if deg.apply_styling() {
1364 crate::apply_style(&mut cell_bl, self.style);
1365 }
1366 frame.buffer.set_fast(render_area.x, bottom_y, cell_bl);
1367
1368 for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1369 let mut cell = Cell::from_char(h);
1370 if deg.apply_styling() {
1371 crate::apply_style(&mut cell, self.style);
1372 }
1373 frame.buffer.set_fast(x, bottom_y, cell);
1374 }
1375
1376 let mut cell_br = Cell::from_char(br);
1377 if deg.apply_styling() {
1378 crate::apply_style(&mut cell_br, self.style);
1379 }
1380 frame
1381 .buffer
1382 .set_fast(render_area.right().saturating_sub(1), bottom_y, cell_br);
1383
1384 for y in (render_area.y + 1)..bottom_y {
1386 let mut cell_l = Cell::from_char(v);
1387 if deg.apply_styling() {
1388 crate::apply_style(&mut cell_l, self.style);
1389 }
1390 frame.buffer.set_fast(render_area.x, y, cell_l);
1391
1392 let mut cell_r = Cell::from_char(v);
1393 if deg.apply_styling() {
1394 crate::apply_style(&mut cell_r, self.style);
1395 }
1396 frame
1397 .buffer
1398 .set_fast(render_area.right().saturating_sub(1), y, cell_r);
1399 }
1400
1401 let content_x = render_area.x + 1; let content_width = width.saturating_sub(2); let mut content_y = render_area.y + 1;
1405
1406 if let Some(ref title) = self.content.title {
1408 let title_style = if deg.apply_styling() {
1409 self.title_style.merge(&self.style)
1410 } else {
1411 Style::default()
1412 };
1413
1414 let title_style = if deg.apply_styling() {
1415 title_style
1416 } else {
1417 Style::default()
1418 };
1419 crate::draw_text_span(
1420 frame,
1421 content_x,
1422 content_y,
1423 title,
1424 title_style,
1425 content_x + content_width,
1426 );
1427 content_y += 1;
1428 }
1429
1430 let mut msg_x = content_x;
1432
1433 if let Some(icon) = self.content.icon {
1434 let icon_char = if use_unicode {
1435 icon.as_char()
1436 } else {
1437 icon.as_ascii()
1438 };
1439
1440 let icon_style = if deg.apply_styling() {
1441 self.icon_style.merge(&self.style)
1442 } else {
1443 Style::default()
1444 };
1445 let icon_str = icon_char.to_string();
1446 msg_x = crate::draw_text_span(
1447 frame,
1448 msg_x,
1449 content_y,
1450 &icon_str,
1451 icon_style,
1452 content_x + content_width,
1453 );
1454 msg_x = crate::draw_text_span(
1455 frame,
1456 msg_x,
1457 content_y,
1458 " ",
1459 Style::default(),
1460 content_x + content_width,
1461 );
1462 }
1463
1464 let msg_style = if deg.apply_styling() {
1466 self.style
1467 } else {
1468 Style::default()
1469 };
1470 crate::draw_text_span(
1471 frame,
1472 msg_x,
1473 content_y,
1474 &self.content.message,
1475 msg_style,
1476 content_x + content_width,
1477 );
1478
1479 if !self.actions.is_empty() {
1481 content_y += 1;
1482 let mut btn_x = content_x;
1483
1484 for (idx, action) in self.actions.iter().enumerate() {
1485 let is_focused = self.state.focused_action == Some(idx);
1486 let btn_style = if is_focused && deg.apply_styling() {
1487 self.action_focus_style.merge(&self.style)
1488 } else if deg.apply_styling() {
1489 self.action_style.merge(&self.style)
1490 } else {
1491 Style::default()
1492 };
1493
1494 let max_x = content_x + content_width;
1495 let label = format!("[{}]", action.label);
1496 btn_x = crate::draw_text_span(frame, btn_x, content_y, &label, btn_style, max_x);
1497
1498 if idx + 1 < self.actions.len() {
1500 btn_x = crate::draw_text_span(
1501 frame,
1502 btn_x,
1503 content_y,
1504 " ",
1505 Style::default(),
1506 max_x,
1507 );
1508 }
1509 }
1510 }
1511 }
1512
1513 fn is_essential(&self) -> bool {
1514 false
1516 }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 use super::*;
1522 use ftui_render::budget::DegradationLevel;
1523 use ftui_render::grapheme_pool::GraphemePool;
1524
1525 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
1526 frame
1527 .buffer
1528 .get(x, y)
1529 .copied()
1530 .expect("test cell should exist")
1531 }
1532
1533 fn line_text(frame: &Frame, y: u16, width: u16) -> String {
1534 (0..width)
1535 .map(|x| {
1536 frame
1537 .buffer
1538 .get(x, y)
1539 .and_then(|cell| cell.content.as_char())
1540 .unwrap_or(' ')
1541 })
1542 .collect()
1543 }
1544
1545 fn focused_action_id(toast: &Toast) -> &str {
1546 toast
1547 .focused_action()
1548 .expect("focused action should exist")
1549 .id
1550 .as_str()
1551 }
1552
1553 fn unwrap_remaining(remaining: Option<Duration>) -> Duration {
1554 remaining.expect("remaining duration should exist")
1555 }
1556
1557 #[test]
1558 fn test_toast_new() {
1559 let toast = Toast::new("Hello");
1560 assert_eq!(toast.content.message, "Hello");
1561 assert!(toast.content.icon.is_none());
1562 assert!(toast.content.title.is_none());
1563 assert!(!toast.config.duration_explicit);
1564 assert!(toast.is_visible());
1565 }
1566
1567 #[test]
1568 fn test_toast_builder() {
1569 let toast = Toast::new("Test message")
1570 .icon(ToastIcon::Success)
1571 .title("Success")
1572 .position(ToastPosition::BottomRight)
1573 .duration(Duration::from_secs(10))
1574 .max_width(60);
1575
1576 assert_eq!(toast.content.message, "Test message");
1577 assert_eq!(toast.content.icon, Some(ToastIcon::Success));
1578 assert_eq!(toast.content.title, Some("Success".to_string()));
1579 assert_eq!(toast.config.position, ToastPosition::BottomRight);
1580 assert_eq!(toast.config.duration, Some(Duration::from_secs(10)));
1581 assert!(toast.config.duration_explicit);
1582 assert_eq!(toast.config.max_width, 60);
1583 }
1584
1585 #[test]
1586 fn test_toast_persistent() {
1587 let toast = Toast::new("Persistent").persistent();
1588 assert!(toast.config.duration.is_none());
1589 assert!(toast.config.duration_explicit);
1590 assert!(!toast.is_expired());
1591 }
1592
1593 #[test]
1594 fn test_toast_dismiss() {
1595 let mut toast = Toast::new("Dismissable").no_animation();
1596 assert!(toast.is_visible());
1597 toast.dismiss();
1598 assert!(!toast.is_visible());
1599 assert!(toast.state.dismissed);
1600 }
1601
1602 #[test]
1603 fn test_toast_position_calculate() {
1604 let terminal_width = 80;
1605 let terminal_height = 24;
1606 let toast_width = 30;
1607 let toast_height = 3;
1608 let margin = 1;
1609
1610 let (x, y) = ToastPosition::TopLeft.calculate_position(
1612 terminal_width,
1613 terminal_height,
1614 toast_width,
1615 toast_height,
1616 margin,
1617 );
1618 assert_eq!(x, 1);
1619 assert_eq!(y, 1);
1620
1621 let (x, y) = ToastPosition::TopRight.calculate_position(
1623 terminal_width,
1624 terminal_height,
1625 toast_width,
1626 toast_height,
1627 margin,
1628 );
1629 assert_eq!(x, 80 - 30 - 1); assert_eq!(y, 1);
1631
1632 let (x, y) = ToastPosition::BottomRight.calculate_position(
1634 terminal_width,
1635 terminal_height,
1636 toast_width,
1637 toast_height,
1638 margin,
1639 );
1640 assert_eq!(x, 49);
1641 assert_eq!(y, 24 - 3 - 1); let (x, y) = ToastPosition::TopCenter.calculate_position(
1645 terminal_width,
1646 terminal_height,
1647 toast_width,
1648 toast_height,
1649 margin,
1650 );
1651 assert_eq!(x, (80 - 30) / 2); assert_eq!(y, 1);
1653 }
1654
1655 #[test]
1656 fn test_toast_icon_chars() {
1657 assert_eq!(ToastIcon::Success.as_char(), '\u{2713}');
1658 assert_eq!(ToastIcon::Error.as_char(), '\u{2717}');
1659 assert_eq!(ToastIcon::Warning.as_char(), '!');
1660 assert_eq!(ToastIcon::Info.as_char(), 'i');
1661 assert_eq!(ToastIcon::Custom('*').as_char(), '*');
1662
1663 assert_eq!(ToastIcon::Success.as_ascii(), '+');
1665 assert_eq!(ToastIcon::Error.as_ascii(), 'x');
1666 }
1667
1668 #[test]
1669 fn test_toast_dimensions() {
1670 let toast = Toast::new("Short");
1671 let (w, h) = toast.calculate_dimensions();
1672 assert_eq!(w, 9);
1674 assert_eq!(h, 3); let toast_with_title = Toast::new("Message").title("Title");
1677 let (_w, h) = toast_with_title.calculate_dimensions();
1678 assert_eq!(h, 4); }
1680
1681 #[test]
1682 fn test_toast_dimensions_with_icon() {
1683 let toast = Toast::new("Message").icon(ToastIcon::Success);
1684 let (w, _h) = toast.calculate_dimensions();
1685 let mut buf = [0u8; 4];
1686 let icon = ToastIcon::Success.as_char().encode_utf8(&mut buf);
1687 let expected = display_width(icon) + 1 + display_width("Message") + 4;
1688 assert_eq!(w, expected as u16);
1689 }
1690
1691 #[test]
1692 fn test_toast_dimensions_max_width() {
1693 let toast = Toast::new("This is a very long message that exceeds max width").max_width(20);
1694 let (w, _h) = toast.calculate_dimensions();
1695 assert!(w <= 20);
1696 }
1697
1698 #[test]
1699 fn test_toast_render_basic() {
1700 let toast = Toast::new("Hello");
1701 let area = Rect::new(0, 0, 15, 5);
1702 let mut pool = GraphemePool::new();
1703 let mut frame = Frame::new(15, 5, &mut pool);
1704 toast.render(area, &mut frame);
1705
1706 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); assert!(frame.buffer.get(1, 1).is_some()); }
1710
1711 #[test]
1712 fn test_toast_render_with_icon() {
1713 let toast = Toast::new("OK").icon(ToastIcon::Success);
1714 let area = Rect::new(0, 0, 10, 5);
1715 let mut pool = GraphemePool::new();
1716 let mut frame = Frame::new(10, 5, &mut pool);
1717 toast.render(area, &mut frame);
1718
1719 let icon_cell = cell_at(&frame, 1, 1);
1721 let ok = if let Some(ch) = icon_cell.content.as_char() {
1722 ch == '\u{2713}'
1723 } else if let Some(id) = icon_cell.content.grapheme_id() {
1724 frame.pool.get(id) == Some("\u{2713}")
1725 } else {
1726 false
1727 };
1728 assert!(ok, "expected toast icon cell to contain ✓");
1729 }
1730
1731 #[test]
1732 fn test_toast_render_with_title() {
1733 let toast = Toast::new("Body").title("Head");
1734 let area = Rect::new(0, 0, 15, 6);
1735 let mut pool = GraphemePool::new();
1736 let mut frame = Frame::new(15, 6, &mut pool);
1737 toast.render(area, &mut frame);
1738
1739 let title_cell = cell_at(&frame, 1, 1);
1741 assert_eq!(title_cell.content.as_char(), Some('H'));
1742 }
1743
1744 #[test]
1745 fn test_toast_render_zero_area() {
1746 let toast = Toast::new("Test");
1747 let area = Rect::new(0, 0, 0, 0);
1748 let mut pool = GraphemePool::new();
1749 let mut frame = Frame::new(1, 1, &mut pool);
1750 toast.render(area, &mut frame); }
1752
1753 #[test]
1754 fn test_toast_render_small_area() {
1755 let toast = Toast::new("Test");
1756 let area = Rect::new(0, 0, 2, 2);
1757 let mut pool = GraphemePool::new();
1758 let mut frame = Frame::new(2, 2, &mut pool);
1759 toast.render(area, &mut frame); }
1761
1762 #[test]
1763 fn test_toast_not_visible_when_dismissed_clears_previous_render_area() {
1764 let mut toast = Toast::new("Test").no_animation();
1765 let area = Rect::new(0, 0, 20, 5);
1766 let mut pool = GraphemePool::new();
1767 let mut frame = Frame::new(20, 5, &mut pool);
1768 let (toast_width, toast_height) = toast.calculate_dimensions();
1769
1770 toast.render(area, &mut frame);
1771 toast.dismiss();
1772
1773 toast.render(area, &mut frame);
1774
1775 for y in 0..toast_height.min(area.height) {
1776 for x in 0..toast_width.min(area.width) {
1777 assert_eq!(cell_at(&frame, x, y).content.as_char(), Some(' '));
1778 }
1779 }
1780 }
1781
1782 #[test]
1783 fn test_toast_is_not_essential() {
1784 let toast = Toast::new("Test");
1785 assert!(!toast.is_essential());
1786 }
1787
1788 #[test]
1789 fn test_toast_simple_borders_use_ascii() {
1790 let toast = Toast::new("Hello");
1791 let area = Rect::new(0, 0, 15, 5);
1792 let mut pool = GraphemePool::new();
1793 let mut frame = Frame::new(15, 5, &mut pool);
1794 frame.buffer.degradation = DegradationLevel::SimpleBorders;
1795 toast.render(area, &mut frame);
1796
1797 assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('+'));
1798 assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('-'));
1799 assert_eq!(cell_at(&frame, 0, 1).content.as_char(), Some('|'));
1800 }
1801
1802 #[test]
1803 fn test_toast_skeleton_is_noop() {
1804 let toast = Toast::new("Hello").style_variant(ToastStyle::Success);
1805 let area = Rect::new(0, 0, 15, 5);
1806 let mut pool = GraphemePool::new();
1807 let mut frame = Frame::new(15, 5, &mut pool);
1808 let mut expected_pool = GraphemePool::new();
1809 let expected = Frame::new(15, 5, &mut expected_pool);
1810 frame.buffer.degradation = DegradationLevel::Skeleton;
1811 toast.render(area, &mut frame);
1812
1813 for y in 0..5 {
1814 for x in 0..15 {
1815 assert_eq!(frame.buffer.get(x, y), expected.buffer.get(x, y));
1816 }
1817 }
1818 }
1819
1820 #[test]
1821 fn test_toast_render_shorter_message_clears_stale_suffix() {
1822 let area = Rect::new(0, 0, 20, 5);
1823 let mut pool = GraphemePool::new();
1824 let mut frame = Frame::new(20, 5, &mut pool);
1825
1826 Toast::new("Long message text")
1827 .max_width(18)
1828 .no_animation()
1829 .render(area, &mut frame);
1830 Toast::new("Hi")
1831 .max_width(18)
1832 .no_animation()
1833 .render(area, &mut frame);
1834
1835 assert_eq!(line_text(&frame, 1, 6), "│Hi │");
1836 }
1837
1838 #[test]
1839 fn test_toast_no_styling_shorter_title_and_message_clear_stale_text() {
1840 let area = Rect::new(0, 0, 18, 6);
1841 let mut pool = GraphemePool::new();
1842 let mut frame = Frame::new(18, 6, &mut pool);
1843
1844 Toast::new("Long body")
1845 .title("LongTitle")
1846 .max_width(16)
1847 .no_animation()
1848 .render(area, &mut frame);
1849
1850 frame.buffer.degradation = DegradationLevel::NoStyling;
1851 Toast::new("Ok")
1852 .title("Hi")
1853 .max_width(16)
1854 .no_animation()
1855 .render(area, &mut frame);
1856
1857 assert_eq!(line_text(&frame, 1, 6), "|Hi |");
1858 assert_eq!(line_text(&frame, 2, 6), "|Ok |");
1859 }
1860
1861 #[test]
1862 fn test_toast_id_uniqueness() {
1863 let toast1 = Toast::new("A");
1864 let toast2 = Toast::new("B");
1865 assert_ne!(toast1.id, toast2.id);
1866 }
1867
1868 #[test]
1869 fn test_toast_style_variants() {
1870 let success = Toast::new("OK").style_variant(ToastStyle::Success);
1871 let error = Toast::new("Fail").style_variant(ToastStyle::Error);
1872 let warning = Toast::new("Warn").style_variant(ToastStyle::Warning);
1873 let info = Toast::new("Info").style_variant(ToastStyle::Info);
1874 let neutral = Toast::new("Neutral").style_variant(ToastStyle::Neutral);
1875
1876 assert_eq!(success.config.style_variant, ToastStyle::Success);
1877 assert_eq!(error.config.style_variant, ToastStyle::Error);
1878 assert_eq!(warning.config.style_variant, ToastStyle::Warning);
1879 assert_eq!(info.config.style_variant, ToastStyle::Info);
1880 assert_eq!(neutral.config.style_variant, ToastStyle::Neutral);
1881 }
1882
1883 #[test]
1884 fn test_toast_content_builder() {
1885 let content = ToastContent::new("Message")
1886 .with_icon(ToastIcon::Warning)
1887 .with_title("Alert");
1888
1889 assert_eq!(content.message, "Message");
1890 assert_eq!(content.icon, Some(ToastIcon::Warning));
1891 assert_eq!(content.title, Some("Alert".to_string()));
1892 }
1893
1894 #[test]
1897 fn test_animation_phase_default() {
1898 let toast = Toast::new("Test");
1899 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
1900 }
1901
1902 #[test]
1903 fn test_animation_phase_reduced_motion() {
1904 let toast = Toast::new("Test").reduced_motion(true);
1905 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1906 assert!(toast.state.animation.reduced_motion);
1907 }
1908
1909 #[test]
1910 fn test_animation_no_animation() {
1911 let toast = Toast::new("Test").no_animation();
1912 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1913 assert!(toast.config.animation.is_disabled());
1914 }
1915
1916 #[test]
1917 fn test_entrance_animation_builder() {
1918 let toast = Toast::new("Test")
1919 .entrance_animation(ToastEntranceAnimation::SlideFromTop)
1920 .entrance_duration(Duration::from_millis(300))
1921 .entrance_easing(ToastEasing::Bounce);
1922
1923 assert_eq!(
1924 toast.config.animation.entrance,
1925 ToastEntranceAnimation::SlideFromTop
1926 );
1927 assert_eq!(
1928 toast.config.animation.entrance_duration,
1929 Duration::from_millis(300)
1930 );
1931 assert_eq!(toast.config.animation.entrance_easing, ToastEasing::Bounce);
1932 }
1933
1934 #[test]
1935 fn test_exit_animation_builder() {
1936 let toast = Toast::new("Test")
1937 .exit_animation(ToastExitAnimation::SlideOut)
1938 .exit_duration(Duration::from_millis(100))
1939 .exit_easing(ToastEasing::EaseInOut);
1940
1941 assert_eq!(toast.config.animation.exit, ToastExitAnimation::SlideOut);
1942 assert_eq!(
1943 toast.config.animation.exit_duration,
1944 Duration::from_millis(100)
1945 );
1946 assert_eq!(toast.config.animation.exit_easing, ToastEasing::EaseInOut);
1947 }
1948
1949 #[test]
1950 fn test_entrance_animation_offsets() {
1951 let width = 30u16;
1952 let height = 5u16;
1953
1954 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1956 assert_eq!(dx, 0);
1957 assert_eq!(dy, -(height as i16));
1958
1959 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(0.0, width, height);
1961 assert_eq!(dx, 0);
1962 assert_eq!(dy, -(height as i16));
1963
1964 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1966 assert_eq!(dx, 0);
1967 assert_eq!(dy, 0);
1968
1969 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.initial_offset(width, height);
1971 assert_eq!(dx, width as i16);
1972 assert_eq!(dy, 0);
1973 }
1974
1975 #[test]
1976 fn test_exit_animation_offsets() {
1977 let width = 30u16;
1978 let height = 5u16;
1979 let entrance = ToastEntranceAnimation::SlideFromRight;
1980
1981 let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1983 assert_eq!(dx, -(width as i16)); assert_eq!(dy, 0);
1985
1986 let (dx, dy) =
1988 ToastExitAnimation::SlideOut.offset_at_progress(0.0, width, height, entrance);
1989 assert_eq!(dx, 0);
1990 assert_eq!(dy, 0);
1991
1992 let (dx, dy) =
1994 ToastExitAnimation::SlideOut.offset_at_progress(1.0, width, height, entrance);
1995 assert_eq!(dx, -(width as i16));
1996 assert_eq!(dy, 0);
1997 }
1998
1999 #[test]
2000 fn test_easing_apply() {
2001 assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
2003
2004 assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
2006
2007 assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
2009
2010 for easing in [
2012 ToastEasing::Linear,
2013 ToastEasing::EaseIn,
2014 ToastEasing::EaseOut,
2015 ToastEasing::EaseInOut,
2016 ToastEasing::Bounce,
2017 ] {
2018 assert!((easing.apply(0.0) - 0.0).abs() < 0.001, "{:?} at 0", easing);
2019 assert!((easing.apply(1.0) - 1.0).abs() < 0.001, "{:?} at 1", easing);
2020 }
2021 }
2022
2023 #[test]
2024 fn test_animation_state_progress() {
2025 let state = ToastAnimationState::new();
2026 let progress = state.progress(Duration::from_millis(200));
2028 assert!(
2029 progress < 0.1,
2030 "Progress should be small immediately after creation"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_animation_state_zero_duration() {
2036 let state = ToastAnimationState::new();
2037 let progress = state.progress(Duration::ZERO);
2039 assert_eq!(progress, 1.0);
2040 }
2041
2042 #[test]
2043 fn test_dismiss_starts_exit_animation() {
2044 let mut toast = Toast::new("Test").no_animation();
2045 toast.state.animation.phase = ToastAnimationPhase::Visible;
2047 toast.state.animation.reduced_motion = false;
2048
2049 toast.dismiss();
2050
2051 assert!(toast.state.dismissed);
2052 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Exiting);
2053 }
2054
2055 #[test]
2056 fn test_dismiss_immediately() {
2057 let mut toast = Toast::new("Test");
2058 toast.dismiss_immediately();
2059
2060 assert!(toast.state.dismissed);
2061 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Hidden);
2062 assert!(!toast.is_visible());
2063 }
2064
2065 #[test]
2066 fn test_is_animating() {
2067 let toast = Toast::new("Test");
2068 assert!(toast.is_animating()); let toast_visible = Toast::new("Test").no_animation();
2071 assert!(!toast_visible.is_animating()); }
2073
2074 #[test]
2075 fn test_animation_opacity_fade_in() {
2076 let config = ToastAnimationConfig {
2077 entrance: ToastEntranceAnimation::FadeIn,
2078 exit: ToastExitAnimation::FadeOut,
2079 entrance_duration: Duration::from_millis(200),
2080 exit_duration: Duration::from_millis(150),
2081 entrance_easing: ToastEasing::Linear,
2082 exit_easing: ToastEasing::Linear,
2083 respect_reduced_motion: false,
2084 };
2085
2086 let mut state = ToastAnimationState::new();
2088 let opacity = state.current_opacity(&config);
2089 assert!(opacity < 0.1, "Should be low opacity at start");
2090
2091 state.phase = ToastAnimationPhase::Visible;
2093 let opacity = state.current_opacity(&config);
2094 assert!((opacity - 1.0).abs() < 0.001);
2095 }
2096
2097 #[test]
2098 fn test_animation_config_default() {
2099 let config = ToastAnimationConfig::default();
2100
2101 assert_eq!(config.entrance, ToastEntranceAnimation::SlideFromRight);
2102 assert_eq!(config.exit, ToastExitAnimation::FadeOut);
2103 assert_eq!(config.entrance_duration, Duration::from_millis(200));
2104 assert_eq!(config.exit_duration, Duration::from_millis(150));
2105 assert!(config.respect_reduced_motion);
2106 }
2107
2108 #[test]
2109 fn test_animation_affects_position() {
2110 assert!(ToastEntranceAnimation::SlideFromTop.affects_position());
2111 assert!(ToastEntranceAnimation::SlideFromRight.affects_position());
2112 assert!(!ToastEntranceAnimation::FadeIn.affects_position());
2113 assert!(!ToastEntranceAnimation::None.affects_position());
2114
2115 assert!(ToastExitAnimation::SlideOut.affects_position());
2116 assert!(ToastExitAnimation::SlideToLeft.affects_position());
2117 assert!(!ToastExitAnimation::FadeOut.affects_position());
2118 assert!(!ToastExitAnimation::None.affects_position());
2119 }
2120
2121 #[test]
2122 fn test_toast_animation_offset() {
2123 let toast = Toast::new("Test").entrance_animation(ToastEntranceAnimation::SlideFromRight);
2124 let (dx, dy) = toast.animation_offset();
2125 assert!(dx > 0, "Should have positive x offset at start");
2127 assert_eq!(dy, 0);
2128 }
2129
2130 #[test]
2133 fn action_builder_single() {
2134 let toast = Toast::new("msg").action(ToastAction::new("Retry", "retry"));
2135 assert_eq!(toast.actions.len(), 1);
2136 assert_eq!(toast.actions[0].label, "Retry");
2137 assert_eq!(toast.actions[0].id, "retry");
2138 }
2139
2140 #[test]
2141 fn action_builder_multiple() {
2142 let toast = Toast::new("msg")
2143 .action(ToastAction::new("Ack", "ack"))
2144 .action(ToastAction::new("Snooze", "snooze"));
2145 assert_eq!(toast.actions.len(), 2);
2146 }
2147
2148 #[test]
2149 fn action_builder_vec() {
2150 let actions = vec![
2151 ToastAction::new("A", "a"),
2152 ToastAction::new("B", "b"),
2153 ToastAction::new("C", "c"),
2154 ];
2155 let toast = Toast::new("msg").actions(actions);
2156 assert_eq!(toast.actions.len(), 3);
2157 }
2158
2159 #[test]
2160 fn action_display_width() {
2161 let a = ToastAction::new("OK", "ok");
2162 assert_eq!(a.display_width(), 4);
2164 }
2165
2166 #[test]
2167 fn handle_key_esc_dismisses() {
2168 let mut toast = Toast::new("msg").no_animation();
2169 let result = toast.handle_key(KeyEvent::Esc);
2170 assert_eq!(result, ToastEvent::Dismissed);
2171 }
2172
2173 #[test]
2174 fn handle_key_esc_clears_focus_first() {
2175 let mut toast = Toast::new("msg")
2176 .action(ToastAction::new("A", "a"))
2177 .no_animation();
2178 toast.handle_key(KeyEvent::Tab);
2180 assert!(toast.has_focus());
2181 let result = toast.handle_key(KeyEvent::Esc);
2183 assert_eq!(result, ToastEvent::None);
2184 assert!(!toast.has_focus());
2185 }
2186
2187 #[test]
2188 fn handle_key_tab_cycles_focus() {
2189 let mut toast = Toast::new("msg")
2190 .action(ToastAction::new("A", "a"))
2191 .action(ToastAction::new("B", "b"))
2192 .no_animation();
2193
2194 let r1 = toast.handle_key(KeyEvent::Tab);
2195 assert_eq!(r1, ToastEvent::FocusChanged);
2196 assert_eq!(toast.state.focused_action, Some(0));
2197
2198 let r2 = toast.handle_key(KeyEvent::Tab);
2199 assert_eq!(r2, ToastEvent::FocusChanged);
2200 assert_eq!(toast.state.focused_action, Some(1));
2201
2202 let r3 = toast.handle_key(KeyEvent::Tab);
2204 assert_eq!(r3, ToastEvent::FocusChanged);
2205 assert_eq!(toast.state.focused_action, Some(0));
2206 }
2207
2208 #[test]
2209 fn handle_key_tab_no_actions_is_noop() {
2210 let mut toast = Toast::new("msg").no_animation();
2211 let result = toast.handle_key(KeyEvent::Tab);
2212 assert_eq!(result, ToastEvent::None);
2213 }
2214
2215 #[test]
2216 fn handle_key_enter_invokes_action() {
2217 let mut toast = Toast::new("msg")
2218 .action(ToastAction::new("Retry", "retry"))
2219 .no_animation();
2220 toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2222 assert_eq!(result, ToastEvent::Action("retry".into()));
2223 }
2224
2225 #[test]
2226 fn handle_key_enter_no_focus_is_noop() {
2227 let mut toast = Toast::new("msg")
2228 .action(ToastAction::new("A", "a"))
2229 .no_animation();
2230 let result = toast.handle_key(KeyEvent::Enter);
2231 assert_eq!(result, ToastEvent::None);
2232 }
2233
2234 #[test]
2235 fn handle_key_other_is_noop() {
2236 let mut toast = Toast::new("msg").no_animation();
2237 let result = toast.handle_key(KeyEvent::Other);
2238 assert_eq!(result, ToastEvent::None);
2239 }
2240
2241 #[test]
2242 fn handle_key_dismissed_toast_is_noop() {
2243 let mut toast = Toast::new("msg").no_animation();
2244 toast.state.dismissed = true;
2245 let result = toast.handle_key(KeyEvent::Esc);
2246 assert_eq!(result, ToastEvent::None);
2247 }
2248
2249 #[test]
2250 fn pause_timer_sets_flag() {
2251 let mut toast = Toast::new("msg").no_animation();
2252 toast.pause_timer();
2253 assert!(toast.state.timer_paused);
2254 assert!(toast.state.pause_started.is_some());
2255 }
2256
2257 #[test]
2258 fn resume_timer_accumulates_paused() {
2259 let mut toast = Toast::new("msg").no_animation();
2260 toast.pause_timer();
2261 std::thread::sleep(Duration::from_millis(10));
2262 toast.resume_timer();
2263 assert!(!toast.state.timer_paused);
2264 assert!(toast.state.total_paused >= Duration::from_millis(5));
2265 }
2266
2267 #[test]
2268 fn pause_resume_idempotent() {
2269 let mut toast = Toast::new("msg").no_animation();
2270 toast.pause_timer();
2272 toast.pause_timer();
2273 assert!(toast.state.timer_paused);
2274 toast.resume_timer();
2276 toast.resume_timer();
2277 assert!(!toast.state.timer_paused);
2278 }
2279
2280 #[test]
2281 fn resume_timer_saturates_paused_duration() {
2282 let mut toast = Toast::new("msg").no_animation();
2283 toast.state.total_paused = Duration::MAX;
2284 toast.pause_timer();
2285 std::thread::sleep(Duration::from_millis(1));
2286 toast.resume_timer();
2287 assert_eq!(toast.state.total_paused, Duration::MAX);
2288 }
2289
2290 #[test]
2291 fn active_pause_queries_saturate_paused_duration() {
2292 let mut toast = Toast::new("msg")
2293 .duration(Duration::from_secs(1))
2294 .no_animation();
2295 toast.state.total_paused = Duration::MAX;
2296 toast.pause_timer();
2297 std::thread::sleep(Duration::from_millis(1));
2298
2299 assert!(!toast.is_expired());
2300 assert_eq!(toast.remaining_time(), Some(Duration::from_secs(1)));
2301 }
2302
2303 #[test]
2304 fn clear_focus_resumes_timer() {
2305 let mut toast = Toast::new("msg")
2306 .action(ToastAction::new("A", "a"))
2307 .no_animation();
2308 toast.handle_key(KeyEvent::Tab);
2309 assert!(toast.state.timer_paused);
2310 toast.clear_focus();
2311 assert!(!toast.has_focus());
2312 assert!(!toast.state.timer_paused);
2313 }
2314
2315 #[test]
2316 fn focused_action_returns_correct() {
2317 let mut toast = Toast::new("msg")
2318 .action(ToastAction::new("X", "x"))
2319 .action(ToastAction::new("Y", "y"))
2320 .no_animation();
2321 assert!(toast.focused_action().is_none());
2322 toast.handle_key(KeyEvent::Tab);
2323 assert_eq!(focused_action_id(&toast), "x");
2324 toast.handle_key(KeyEvent::Tab);
2325 assert_eq!(focused_action_id(&toast), "y");
2326 }
2327
2328 #[test]
2329 fn is_expired_accounts_for_pause() {
2330 let mut toast = Toast::new("msg")
2331 .duration(Duration::from_millis(50))
2332 .no_animation();
2333 toast.pause_timer();
2334 std::thread::sleep(Duration::from_millis(60));
2336 assert!(
2337 !toast.is_expired(),
2338 "Should not expire while timer is paused"
2339 );
2340 toast.resume_timer();
2341 assert!(
2343 !toast.is_expired(),
2344 "Should not expire immediately after resume because paused time was subtracted"
2345 );
2346 }
2347
2348 #[test]
2349 fn dimensions_include_actions_row() {
2350 let toast = Toast::new("Hi")
2351 .action(ToastAction::new("OK", "ok"))
2352 .no_animation();
2353 let (_, h) = toast.calculate_dimensions();
2354 assert_eq!(h, 4);
2357 }
2358
2359 #[test]
2360 fn dimensions_with_title_and_actions() {
2361 let toast = Toast::new("Hi")
2362 .title("Title")
2363 .action(ToastAction::new("OK", "ok"))
2364 .no_animation();
2365 let (_, h) = toast.calculate_dimensions();
2366 assert_eq!(h, 5);
2368 }
2369
2370 #[test]
2371 fn dimensions_width_accounts_for_actions() {
2372 let toast = Toast::new("Hi")
2373 .action(ToastAction::new("LongButtonLabel", "lb"))
2374 .no_animation();
2375 let (w, _) = toast.calculate_dimensions();
2376 assert!(w >= 20);
2379 }
2380
2381 #[test]
2382 fn render_with_actions_does_not_panic() {
2383 let toast = Toast::new("Test")
2384 .action(ToastAction::new("OK", "ok"))
2385 .action(ToastAction::new("Cancel", "cancel"))
2386 .no_animation();
2387
2388 let mut pool = GraphemePool::new();
2389 let mut frame = Frame::new(60, 20, &mut pool);
2390 let area = Rect::new(0, 0, 40, 10);
2391 toast.render(area, &mut frame);
2392 }
2393
2394 #[test]
2395 fn render_focused_action_does_not_panic() {
2396 let mut toast = Toast::new("Test")
2397 .action(ToastAction::new("OK", "ok"))
2398 .no_animation();
2399 toast.handle_key(KeyEvent::Tab); let mut pool = GraphemePool::new();
2402 let mut frame = Frame::new(60, 20, &mut pool);
2403 let area = Rect::new(0, 0, 40, 10);
2404 toast.render(area, &mut frame);
2405 }
2406
2407 #[test]
2408 fn render_actions_tiny_area_does_not_panic() {
2409 let toast = Toast::new("X")
2410 .action(ToastAction::new("A", "a"))
2411 .no_animation();
2412
2413 let mut pool = GraphemePool::new();
2414 let mut frame = Frame::new(5, 3, &mut pool);
2415 let area = Rect::new(0, 0, 5, 3);
2416 toast.render(area, &mut frame);
2417 }
2418
2419 #[test]
2420 fn toast_action_styles() {
2421 let style = Style::new().bold();
2422 let focus_style = Style::new().italic();
2423 let toast = Toast::new("msg")
2424 .action(ToastAction::new("A", "a"))
2425 .with_action_style(style)
2426 .with_action_focus_style(focus_style);
2427 assert_eq!(toast.action_style, style);
2428 assert_eq!(toast.action_focus_style, focus_style);
2429 }
2430
2431 #[test]
2432 fn persistent_toast_not_expired_with_actions() {
2433 let toast = Toast::new("msg")
2434 .persistent()
2435 .action(ToastAction::new("Dismiss", "dismiss"))
2436 .no_animation();
2437 std::thread::sleep(Duration::from_millis(10));
2438 assert!(!toast.is_expired());
2439 }
2440
2441 #[test]
2442 fn action_invoke_second_button() {
2443 let mut toast = Toast::new("msg")
2444 .action(ToastAction::new("A", "a"))
2445 .action(ToastAction::new("B", "b"))
2446 .no_animation();
2447 toast.handle_key(KeyEvent::Tab); toast.handle_key(KeyEvent::Tab); let result = toast.handle_key(KeyEvent::Enter);
2450 assert_eq!(result, ToastEvent::Action("b".into()));
2451 }
2452
2453 #[test]
2454 fn remaining_time_with_pause() {
2455 let toast = Toast::new("msg")
2456 .duration(Duration::from_secs(10))
2457 .no_animation();
2458 let remaining = toast.remaining_time();
2459 assert!(remaining.is_some());
2460 let r = unwrap_remaining(remaining);
2461 assert!(r > Duration::from_secs(9));
2462 }
2463
2464 #[test]
2469 fn position_bottom_left() {
2470 let (x, y) = ToastPosition::BottomLeft.calculate_position(80, 24, 20, 3, 1);
2471 assert_eq!(x, 1);
2472 assert_eq!(y, 24 - 3 - 1); }
2474
2475 #[test]
2476 fn position_bottom_center() {
2477 let (x, y) = ToastPosition::BottomCenter.calculate_position(80, 24, 20, 3, 1);
2478 assert_eq!(x, (80 - 20) / 2); assert_eq!(y, 24 - 3 - 1); }
2481
2482 #[test]
2483 fn position_toast_wider_than_terminal_saturates() {
2484 let (x, y) = ToastPosition::TopRight.calculate_position(20, 10, 30, 3, 1);
2486 assert_eq!(x, 0); assert_eq!(y, 1);
2488 }
2489
2490 #[test]
2491 fn position_zero_margin() {
2492 let (x, y) = ToastPosition::TopLeft.calculate_position(80, 24, 20, 3, 0);
2493 assert_eq!(x, 0);
2494 assert_eq!(y, 0);
2495
2496 let (x, y) = ToastPosition::BottomRight.calculate_position(80, 24, 20, 3, 0);
2497 assert_eq!(x, 60);
2498 assert_eq!(y, 21);
2499 }
2500
2501 #[test]
2502 fn position_toast_taller_than_terminal_saturates() {
2503 let (_, y) = ToastPosition::BottomLeft.calculate_position(80, 3, 20, 10, 1);
2504 assert_eq!(y, 0); }
2506
2507 #[test]
2512 fn icon_custom_non_ascii_falls_back_to_star() {
2513 let icon = ToastIcon::Custom('\u{1F525}'); assert_eq!(icon.as_char(), '\u{1F525}');
2515 assert_eq!(icon.as_ascii(), '*');
2516 }
2517
2518 #[test]
2519 fn icon_custom_ascii_preserved() {
2520 let icon = ToastIcon::Custom('#');
2521 assert_eq!(icon.as_char(), '#');
2522 assert_eq!(icon.as_ascii(), '#');
2523 }
2524
2525 #[test]
2526 fn icon_warning_ascii_same() {
2527 assert_eq!(ToastIcon::Warning.as_ascii(), '!');
2528 assert_eq!(ToastIcon::Info.as_ascii(), 'i');
2529 }
2530
2531 #[test]
2536 fn toast_position_default_is_top_right() {
2537 assert_eq!(ToastPosition::default(), ToastPosition::TopRight);
2538 }
2539
2540 #[test]
2541 fn toast_icon_default_is_info() {
2542 assert_eq!(ToastIcon::default(), ToastIcon::Info);
2543 }
2544
2545 #[test]
2546 fn toast_style_default_is_info() {
2547 assert_eq!(ToastStyle::default(), ToastStyle::Info);
2548 }
2549
2550 #[test]
2551 fn toast_animation_phase_default_is_visible() {
2552 assert_eq!(ToastAnimationPhase::default(), ToastAnimationPhase::Visible);
2553 }
2554
2555 #[test]
2556 fn toast_entrance_animation_default_is_slide_from_right() {
2557 assert_eq!(
2558 ToastEntranceAnimation::default(),
2559 ToastEntranceAnimation::SlideFromRight
2560 );
2561 }
2562
2563 #[test]
2564 fn toast_exit_animation_default_is_fade_out() {
2565 assert_eq!(ToastExitAnimation::default(), ToastExitAnimation::FadeOut);
2566 }
2567
2568 #[test]
2569 fn toast_easing_default_is_ease_out() {
2570 assert_eq!(ToastEasing::default(), ToastEasing::EaseOut);
2571 }
2572
2573 #[test]
2578 fn entrance_slide_from_bottom_offset() {
2579 let (dx, dy) = ToastEntranceAnimation::SlideFromBottom.initial_offset(20, 5);
2580 assert_eq!(dx, 0);
2581 assert_eq!(dy, 5); }
2583
2584 #[test]
2585 fn entrance_slide_from_left_offset() {
2586 let (dx, dy) = ToastEntranceAnimation::SlideFromLeft.initial_offset(20, 5);
2587 assert_eq!(dx, -20);
2588 assert_eq!(dy, 0);
2589 }
2590
2591 #[test]
2592 fn entrance_fade_in_no_offset() {
2593 let (dx, dy) = ToastEntranceAnimation::FadeIn.initial_offset(20, 5);
2594 assert_eq!(dx, 0);
2595 assert_eq!(dy, 0);
2596 }
2597
2598 #[test]
2599 fn entrance_none_no_offset() {
2600 let (dx, dy) = ToastEntranceAnimation::None.initial_offset(20, 5);
2601 assert_eq!(dx, 0);
2602 assert_eq!(dy, 0);
2603 }
2604
2605 #[test]
2606 fn entrance_offset_progress_clamped() {
2607 let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(-0.5, 20, 5);
2609 assert_eq!(dx, 0);
2610 assert_eq!(dy, -5); let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(2.0, 20, 5);
2614 assert_eq!(dx, 0);
2615 assert_eq!(dy, 0); }
2617
2618 #[test]
2619 fn entrance_offset_at_half_progress() {
2620 let (dx, dy) = ToastEntranceAnimation::SlideFromRight.offset_at_progress(0.5, 20, 5);
2621 assert_eq!(dx, 10); assert_eq!(dy, 0);
2623 }
2624
2625 #[test]
2626 fn entrance_offsets_saturate_large_dimensions() {
2627 assert_eq!(
2628 ToastEntranceAnimation::SlideFromRight.initial_offset(u16::MAX, u16::MAX),
2629 (i16::MAX, 0)
2630 );
2631 assert_eq!(
2632 ToastEntranceAnimation::SlideFromLeft.initial_offset(u16::MAX, u16::MAX),
2633 (-i16::MAX, 0)
2634 );
2635 assert_eq!(
2636 ToastEntranceAnimation::SlideFromTop.initial_offset(u16::MAX, u16::MAX),
2637 (0, -i16::MAX)
2638 );
2639 }
2640
2641 #[test]
2646 fn exit_slide_to_top_offset() {
2647 let entrance = ToastEntranceAnimation::SlideFromRight;
2648 let (dx, dy) = ToastExitAnimation::SlideToTop.final_offset(20, 5, entrance);
2649 assert_eq!(dx, 0);
2650 assert_eq!(dy, -5);
2651 }
2652
2653 #[test]
2654 fn exit_slide_to_right_offset() {
2655 let entrance = ToastEntranceAnimation::SlideFromRight;
2656 let (dx, dy) = ToastExitAnimation::SlideToRight.final_offset(20, 5, entrance);
2657 assert_eq!(dx, 20);
2658 assert_eq!(dy, 0);
2659 }
2660
2661 #[test]
2662 fn exit_slide_to_bottom_offset() {
2663 let entrance = ToastEntranceAnimation::SlideFromRight;
2664 let (dx, dy) = ToastExitAnimation::SlideToBottom.final_offset(20, 5, entrance);
2665 assert_eq!(dx, 0);
2666 assert_eq!(dy, 5);
2667 }
2668
2669 #[test]
2670 fn exit_slide_to_left_offset() {
2671 let entrance = ToastEntranceAnimation::SlideFromRight;
2672 let (dx, dy) = ToastExitAnimation::SlideToLeft.final_offset(20, 5, entrance);
2673 assert_eq!(dx, -20);
2674 assert_eq!(dy, 0);
2675 }
2676
2677 #[test]
2678 fn exit_fade_out_no_offset() {
2679 let entrance = ToastEntranceAnimation::SlideFromRight;
2680 let (dx, dy) = ToastExitAnimation::FadeOut.final_offset(20, 5, entrance);
2681 assert_eq!(dx, 0);
2682 assert_eq!(dy, 0);
2683 }
2684
2685 #[test]
2686 fn exit_none_no_offset() {
2687 let entrance = ToastEntranceAnimation::SlideFromRight;
2688 let (dx, dy) = ToastExitAnimation::None.final_offset(20, 5, entrance);
2689 assert_eq!(dx, 0);
2690 assert_eq!(dy, 0);
2691 }
2692
2693 #[test]
2694 fn exit_offset_progress_clamped() {
2695 let entrance = ToastEntranceAnimation::SlideFromRight;
2696 let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(-1.0, 20, 5, entrance);
2697 assert_eq!((dx, dy), (0, 0)); let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(5.0, 20, 5, entrance);
2700 assert_eq!((dx, dy), (0, -5)); }
2702
2703 #[test]
2704 fn exit_offsets_saturate_large_dimensions() {
2705 let entrance = ToastEntranceAnimation::SlideFromRight;
2706 assert_eq!(
2707 ToastExitAnimation::SlideToRight.final_offset(u16::MAX, u16::MAX, entrance),
2708 (i16::MAX, 0)
2709 );
2710 assert_eq!(
2711 ToastExitAnimation::SlideToBottom.final_offset(u16::MAX, u16::MAX, entrance),
2712 (0, i16::MAX)
2713 );
2714 assert_eq!(
2715 ToastExitAnimation::SlideOut.final_offset(u16::MAX, u16::MAX, entrance),
2716 (-i16::MAX, 0)
2717 );
2718 }
2719
2720 #[test]
2725 fn easing_clamped_below_zero() {
2726 for easing in [
2727 ToastEasing::Linear,
2728 ToastEasing::EaseIn,
2729 ToastEasing::EaseOut,
2730 ToastEasing::EaseInOut,
2731 ToastEasing::Bounce,
2732 ] {
2733 let result = easing.apply(-0.5);
2734 assert!(
2735 (result - 0.0).abs() < 0.001,
2736 "{easing:?} at -0.5 should clamp to 0"
2737 );
2738 }
2739 }
2740
2741 #[test]
2742 fn easing_clamped_above_one() {
2743 for easing in [
2744 ToastEasing::Linear,
2745 ToastEasing::EaseIn,
2746 ToastEasing::EaseOut,
2747 ToastEasing::EaseInOut,
2748 ToastEasing::Bounce,
2749 ] {
2750 let result = easing.apply(1.5);
2751 assert!(
2752 (result - 1.0).abs() < 0.001,
2753 "{easing:?} at 1.5 should clamp to 1"
2754 );
2755 }
2756 }
2757
2758 #[test]
2759 fn easing_ease_in_out_first_half() {
2760 let result = ToastEasing::EaseInOut.apply(0.25);
2761 assert!(
2762 result < 0.25,
2763 "EaseInOut at 0.25 should be < 0.25 (accelerating)"
2764 );
2765 }
2766
2767 #[test]
2768 fn easing_ease_in_out_second_half() {
2769 let result = ToastEasing::EaseInOut.apply(0.75);
2770 assert!(
2771 result > 0.75,
2772 "EaseInOut at 0.75 should be > 0.75 (decelerating)"
2773 );
2774 }
2775
2776 #[test]
2777 fn easing_bounce_monotonic_at_key_points() {
2778 let d1 = 2.75;
2779 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);
2786 let v2 = ToastEasing::Bounce.apply(t2);
2787 let v3 = ToastEasing::Bounce.apply(t3);
2788 let v4 = ToastEasing::Bounce.apply(t4);
2789
2790 assert!((0.0..=1.0).contains(&v1), "branch 1: {v1}");
2791 assert!((0.0..=1.0).contains(&v2), "branch 2: {v2}");
2792 assert!((0.0..=1.0).contains(&v3), "branch 3: {v3}");
2793 assert!((0.0..=1.0).contains(&v4), "branch 4: {v4}");
2794 }
2795
2796 #[test]
2801 fn animation_state_tick_entering_to_visible() {
2802 let config = ToastAnimationConfig {
2803 entrance_duration: Duration::ZERO, ..ToastAnimationConfig::default()
2805 };
2806 let mut state = ToastAnimationState::new();
2807 assert_eq!(state.phase, ToastAnimationPhase::Entering);
2808
2809 let changed = state.tick(&config);
2810 assert!(changed, "Phase should change from Entering to Visible");
2811 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2812 }
2813
2814 #[test]
2815 fn animation_state_tick_exiting_to_hidden() {
2816 let config = ToastAnimationConfig {
2817 exit_duration: Duration::ZERO,
2818 ..ToastAnimationConfig::default()
2819 };
2820 let mut state = ToastAnimationState::new();
2821 state.transition_to(ToastAnimationPhase::Exiting);
2822
2823 let changed = state.tick(&config);
2824 assert!(changed, "Phase should change from Exiting to Hidden");
2825 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2826 }
2827
2828 #[test]
2829 fn animation_state_tick_visible_no_change() {
2830 let config = ToastAnimationConfig::default();
2831 let mut state = ToastAnimationState::new();
2832 state.transition_to(ToastAnimationPhase::Visible);
2833
2834 let changed = state.tick(&config);
2835 assert!(!changed, "Visible phase should not auto-transition");
2836 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2837 }
2838
2839 #[test]
2840 fn animation_state_tick_hidden_no_change() {
2841 let config = ToastAnimationConfig::default();
2842 let mut state = ToastAnimationState::new();
2843 state.transition_to(ToastAnimationPhase::Hidden);
2844
2845 let changed = state.tick(&config);
2846 assert!(!changed);
2847 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2848 }
2849
2850 #[test]
2851 fn animation_state_start_exit_reduced_motion_goes_to_hidden() {
2852 let mut state = ToastAnimationState::with_reduced_motion();
2853 assert_eq!(state.phase, ToastAnimationPhase::Visible);
2854 state.start_exit();
2855 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2856 }
2857
2858 #[test]
2859 fn animation_state_is_complete() {
2860 let mut state = ToastAnimationState::new();
2861 assert!(!state.is_complete());
2862 state.transition_to(ToastAnimationPhase::Hidden);
2863 assert!(state.is_complete());
2864 }
2865
2866 #[test]
2871 fn animation_offset_visible_is_zero() {
2872 let config = ToastAnimationConfig::default();
2873 let mut state = ToastAnimationState::new();
2874 state.phase = ToastAnimationPhase::Visible;
2875 let (dx, dy) = state.current_offset(&config, 20, 5);
2876 assert_eq!((dx, dy), (0, 0));
2877 }
2878
2879 #[test]
2880 fn animation_offset_hidden_is_zero() {
2881 let config = ToastAnimationConfig::default();
2882 let mut state = ToastAnimationState::new();
2883 state.phase = ToastAnimationPhase::Hidden;
2884 let (dx, dy) = state.current_offset(&config, 20, 5);
2885 assert_eq!((dx, dy), (0, 0));
2886 }
2887
2888 #[test]
2889 fn animation_offset_reduced_motion_always_zero() {
2890 let config = ToastAnimationConfig::default();
2891 let state = ToastAnimationState::with_reduced_motion();
2892 let (dx, dy) = state.current_offset(&config, 20, 5);
2893 assert_eq!((dx, dy), (0, 0));
2894 }
2895
2896 #[test]
2897 fn animation_opacity_visible_is_one() {
2898 let config = ToastAnimationConfig::default();
2899 let mut state = ToastAnimationState::new();
2900 state.phase = ToastAnimationPhase::Visible;
2901 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2902 }
2903
2904 #[test]
2905 fn animation_opacity_hidden_is_zero() {
2906 let config = ToastAnimationConfig::default();
2907 let mut state = ToastAnimationState::new();
2908 state.phase = ToastAnimationPhase::Hidden;
2909 assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2910 }
2911
2912 #[test]
2913 fn animation_opacity_reduced_motion_visible_is_one() {
2914 let config = ToastAnimationConfig::default();
2915 let mut state = ToastAnimationState::with_reduced_motion();
2916 state.phase = ToastAnimationPhase::Visible;
2917 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2918 }
2919
2920 #[test]
2921 fn animation_opacity_reduced_motion_hidden_is_zero() {
2922 let config = ToastAnimationConfig::default();
2923 let mut state = ToastAnimationState::with_reduced_motion();
2924 state.phase = ToastAnimationPhase::Hidden;
2925 assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2926 }
2927
2928 #[test]
2929 fn animation_opacity_exiting_non_fade_is_one() {
2930 let config = ToastAnimationConfig {
2931 exit: ToastExitAnimation::SlideOut,
2932 ..ToastAnimationConfig::default()
2933 };
2934 let mut state = ToastAnimationState::new();
2935 state.phase = ToastAnimationPhase::Exiting;
2936 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2938 }
2939
2940 #[test]
2941 fn animation_opacity_entering_non_fade_is_one() {
2942 let config = ToastAnimationConfig {
2943 entrance: ToastEntranceAnimation::SlideFromTop,
2944 ..ToastAnimationConfig::default()
2945 };
2946 let mut state = ToastAnimationState::new();
2947 state.phase = ToastAnimationPhase::Entering;
2948 assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2950 }
2951
2952 #[test]
2957 fn toast_with_id() {
2958 let toast = Toast::with_id(ToastId::new(42), "Custom ID");
2959 assert_eq!(toast.id, ToastId::new(42));
2960 assert_eq!(toast.content.message, "Custom ID");
2961 }
2962
2963 #[test]
2964 fn toast_tick_animation_returns_true_on_phase_change() {
2965 let mut toast = Toast::new("Test").entrance_duration(Duration::ZERO);
2966 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
2967 let changed = toast.tick_animation();
2968 assert!(changed);
2969 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2970 }
2971
2972 #[test]
2973 fn toast_tick_animation_returns_false_when_stable() {
2974 let mut toast = Toast::new("Test").no_animation();
2975 assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2976 let changed = toast.tick_animation();
2977 assert!(!changed);
2978 }
2979
2980 #[test]
2981 fn toast_animation_phase_accessor() {
2982 let toast = Toast::new("Test").no_animation();
2983 assert_eq!(toast.animation_phase(), ToastAnimationPhase::Visible);
2984 }
2985
2986 #[test]
2987 fn toast_animation_opacity_accessor() {
2988 let toast = Toast::new("Test").no_animation();
2989 assert!((toast.animation_opacity() - 1.0).abs() < 0.001);
2990 }
2991
2992 #[test]
2993 fn toast_remaining_time_persistent_is_none() {
2994 let toast = Toast::new("msg").persistent().no_animation();
2995 assert!(toast.remaining_time().is_none());
2996 }
2997
2998 #[test]
2999 fn toast_dismiss_twice_idempotent() {
3000 let mut toast = Toast::new("msg").no_animation();
3001 toast.state.animation.reduced_motion = false;
3002 toast.dismiss();
3003 assert!(toast.state.dismissed);
3004 let phase_after_first = toast.state.animation.phase;
3005 toast.dismiss(); assert_eq!(toast.state.animation.phase, phase_after_first);
3007 }
3008
3009 #[test]
3010 fn toast_non_dismissable_esc_noop() {
3011 let mut toast = Toast::new("msg").dismissable(false).no_animation();
3012 let result = toast.handle_key(KeyEvent::Esc);
3013 assert_eq!(result, ToastEvent::None);
3014 assert!(toast.is_visible());
3015 }
3016
3017 #[test]
3018 fn toast_margin_builder() {
3019 let toast = Toast::new("msg").margin(5);
3020 assert_eq!(toast.config.margin, 5);
3021 }
3022
3023 #[test]
3024 fn toast_with_icon_style_builder() {
3025 let style = Style::new().italic();
3026 let toast = Toast::new("msg").with_icon_style(style);
3027 assert_eq!(toast.icon_style, style);
3028 }
3029
3030 #[test]
3031 fn toast_with_title_style_builder() {
3032 let style = Style::new().bold();
3033 let toast = Toast::new("msg").with_title_style(style);
3034 assert_eq!(toast.title_style, style);
3035 }
3036
3037 #[test]
3042 fn toast_config_default_values() {
3043 let config = ToastConfig::default();
3044 assert_eq!(config.position, ToastPosition::TopRight);
3045 assert_eq!(config.duration, Some(Duration::from_secs(5)));
3046 assert!(!config.duration_explicit);
3047 assert_eq!(config.style_variant, ToastStyle::Info);
3048 assert_eq!(config.max_width, 50);
3049 assert_eq!(config.margin, 1);
3050 assert!(config.dismissable);
3051 }
3052
3053 #[test]
3058 fn animation_config_none_fields() {
3059 let config = ToastAnimationConfig::none();
3060 assert_eq!(config.entrance, ToastEntranceAnimation::None);
3061 assert_eq!(config.exit, ToastExitAnimation::None);
3062 assert_eq!(config.entrance_duration, Duration::ZERO);
3063 assert_eq!(config.exit_duration, Duration::ZERO);
3064 assert!(config.is_disabled());
3065 }
3066
3067 #[test]
3068 fn animation_config_is_disabled_false_for_default() {
3069 let config = ToastAnimationConfig::default();
3070 assert!(!config.is_disabled());
3071 }
3072
3073 #[test]
3078 fn toast_id_hash_consistent() {
3079 use std::collections::HashSet;
3080 let mut set = HashSet::new();
3081 set.insert(ToastId::new(1));
3082 set.insert(ToastId::new(2));
3083 set.insert(ToastId::new(1)); assert_eq!(set.len(), 2);
3085 }
3086
3087 #[test]
3088 fn toast_id_debug() {
3089 let id = ToastId::new(42);
3090 let dbg = format!("{:?}", id);
3091 assert!(dbg.contains("42"), "Debug: {dbg}");
3092 }
3093
3094 #[test]
3095 fn toast_event_debug_clone() {
3096 let event = ToastEvent::Action("test".into());
3097 let dbg = format!("{:?}", event);
3098 assert!(dbg.contains("Action"), "Debug: {dbg}");
3099 let cloned = event.clone();
3100 assert_eq!(cloned, ToastEvent::Action("test".into()));
3101 }
3102
3103 #[test]
3104 fn key_event_traits() {
3105 let key = KeyEvent::Tab;
3106 let copy = key; assert_eq!(key, copy);
3108 let dbg = format!("{:?}", key);
3109 assert!(dbg.contains("Tab"), "Debug: {dbg}");
3110 }
3111
3112 #[test]
3117 fn animation_tick_entering_reduced_motion_transitions_immediately() {
3118 let config = ToastAnimationConfig::default();
3119 let mut state = ToastAnimationState {
3120 phase: ToastAnimationPhase::Entering,
3121 phase_started: Instant::now(),
3122 reduced_motion: true,
3123 };
3124 let changed = state.tick(&config);
3126 assert!(changed);
3127 assert_eq!(state.phase, ToastAnimationPhase::Visible);
3128 }
3129
3130 #[test]
3131 fn animation_tick_exiting_reduced_motion_transitions_immediately() {
3132 let config = ToastAnimationConfig::default();
3133 let mut state = ToastAnimationState {
3134 phase: ToastAnimationPhase::Exiting,
3135 phase_started: Instant::now(),
3136 reduced_motion: true,
3137 };
3138 let changed = state.tick(&config);
3139 assert!(changed);
3140 assert_eq!(state.phase, ToastAnimationPhase::Hidden);
3141 }
3142}