Skip to main content

ftui_widgets/
toast.rs

1#![forbid(unsafe_code)]
2
3//! Toast widget for displaying transient notifications.
4//!
5//! A toast is a non-blocking notification that appears temporarily and
6//! can be dismissed automatically or manually. Toasts support:
7//!
8//! - Multiple positions (corners and center top/bottom)
9//! - Automatic dismissal with configurable duration
10//! - Icons for different message types (success, error, warning, info)
11//! - Semantic styling that integrates with the theme system
12//!
13//! # Example
14//!
15//! ```ignore
16//! let toast = Toast::new("File saved successfully")
17//!     .icon(ToastIcon::Success)
18//!     .position(ToastPosition::TopRight)
19//!     .duration(Duration::from_secs(3));
20//! ```
21
22use 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/// Unique identifier for a toast notification.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct ToastId(pub u64);
34
35impl ToastId {
36    /// Create a new toast ID.
37    pub fn new(id: u64) -> Self {
38        Self(id)
39    }
40}
41
42/// Position where the toast should be displayed.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ToastPosition {
45    /// Top-left corner.
46    TopLeft,
47    /// Top center.
48    TopCenter,
49    /// Top-right corner.
50    #[default]
51    TopRight,
52    /// Bottom-left corner.
53    BottomLeft,
54    /// Bottom center.
55    BottomCenter,
56    /// Bottom-right corner.
57    BottomRight,
58}
59
60impl ToastPosition {
61    /// Calculate the toast's top-left position within a terminal area.
62    ///
63    /// Returns `(x, y)` for the toast's origin given its dimensions.
64    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/// Icon displayed in the toast to indicate message type.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum ToastIcon {
94    /// Success indicator (checkmark).
95    Success,
96    /// Error indicator (X mark).
97    Error,
98    /// Warning indicator (exclamation).
99    Warning,
100    /// Information indicator (i).
101    #[default]
102    Info,
103    /// Custom single character.
104    Custom(char),
105}
106
107impl ToastIcon {
108    /// Get the display character for this icon.
109    pub fn as_char(self) -> char {
110        match self {
111            Self::Success => '\u{2713}', // ✓
112            Self::Error => '\u{2717}',   // ✗
113            Self::Warning => '!',
114            Self::Info => 'i',
115            Self::Custom(c) => c,
116        }
117    }
118
119    /// Get the fallback ASCII character for degraded rendering.
120    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/// Visual style variant for the toast.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum ToastStyle {
135    /// Success style (typically green).
136    Success,
137    /// Error style (typically red).
138    Error,
139    /// Warning style (typically yellow/orange).
140    Warning,
141    /// Informational style (typically blue).
142    #[default]
143    Info,
144    /// Neutral style (no semantic coloring).
145    Neutral,
146}
147
148// ============================================================================
149// Animation Types
150// ============================================================================
151
152/// Animation phase for toast lifecycle.
153///
154/// Toasts progress through these phases: Entering → Visible → Exiting → Hidden.
155/// The animation system tracks progress within each phase.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
157pub enum ToastAnimationPhase {
158    /// Toast is animating in (slide/fade entrance).
159    Entering,
160    /// Toast is fully visible (no animation).
161    #[default]
162    Visible,
163    /// Toast is animating out (slide/fade exit).
164    Exiting,
165    /// Toast has completed exit animation.
166    Hidden,
167}
168
169/// Entrance animation type.
170///
171/// Determines how the toast appears on screen.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
173pub enum ToastEntranceAnimation {
174    /// Slide in from the top edge.
175    SlideFromTop,
176    /// Slide in from the right edge.
177    #[default]
178    SlideFromRight,
179    /// Slide in from the bottom edge.
180    SlideFromBottom,
181    /// Slide in from the left edge.
182    SlideFromLeft,
183    /// Fade in (opacity transition).
184    FadeIn,
185    /// No animation (instant appear).
186    None,
187}
188
189impl ToastEntranceAnimation {
190    /// Get the initial offset for this entrance animation.
191    ///
192    /// Returns (dx, dy) offset in cells from the final position.
193    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    /// Calculate the offset at a given progress (0.0 to 1.0).
204    ///
205    /// Progress of 0.0 = initial offset, 1.0 = no offset.
206    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    /// Check if this animation affects position (vs. just opacity).
221    pub fn affects_position(self) -> bool {
222        !matches!(self, Self::FadeIn | Self::None)
223    }
224}
225
226/// Exit animation type.
227///
228/// Determines how the toast disappears from screen.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub enum ToastExitAnimation {
231    /// Fade out (opacity transition).
232    #[default]
233    FadeOut,
234    /// Slide out in the reverse of entrance direction.
235    SlideOut,
236    /// Slide out to the specified edge.
237    SlideToTop,
238    SlideToRight,
239    SlideToBottom,
240    SlideToLeft,
241    /// No animation (instant disappear).
242    None,
243}
244
245impl ToastExitAnimation {
246    /// Get the final offset for this exit animation.
247    ///
248    /// Returns (dx, dy) offset in cells from the starting position.
249    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                // Reverse of entrance direction
258                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    /// Calculate the offset at a given progress (0.0 to 1.0).
270    ///
271    /// Progress of 0.0 = no offset, 1.0 = final offset.
272    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    /// Check if this animation affects position (vs. just opacity).
288    pub fn affects_position(self) -> bool {
289        !matches!(self, Self::FadeOut | Self::None)
290    }
291}
292
293/// Easing function for animations.
294///
295/// Simplified subset of easing curves for toast animations.
296/// For the full set, see `ftui_extras::text_effects::Easing`.
297#[derive(Debug, Clone, Copy, PartialEq, Default)]
298pub enum ToastEasing {
299    /// Linear interpolation.
300    Linear,
301    /// Smooth ease-out (decelerating).
302    #[default]
303    EaseOut,
304    /// Smooth ease-in (accelerating).
305    EaseIn,
306    /// Smooth S-curve.
307    EaseInOut,
308    /// Bouncy effect.
309    Bounce,
310}
311
312impl ToastEasing {
313    /// Apply the easing function to a progress value (0.0 to 1.0).
314    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/// Animation configuration for a toast.
353#[derive(Debug, Clone)]
354pub struct ToastAnimationConfig {
355    /// Entrance animation type.
356    pub entrance: ToastEntranceAnimation,
357    /// Exit animation type.
358    pub exit: ToastExitAnimation,
359    /// Duration of entrance animation.
360    pub entrance_duration: Duration,
361    /// Duration of exit animation.
362    pub exit_duration: Duration,
363    /// Easing function for entrance.
364    pub entrance_easing: ToastEasing,
365    /// Easing function for exit.
366    pub exit_easing: ToastEasing,
367    /// Whether to respect reduced-motion preference.
368    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    /// Create a config with no animations.
387    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    /// Check if animations are effectively disabled.
398    pub fn is_disabled(&self) -> bool {
399        matches!(self.entrance, ToastEntranceAnimation::None)
400            && matches!(self.exit, ToastExitAnimation::None)
401    }
402}
403
404/// Tracks the animation state for a toast.
405#[derive(Debug, Clone)]
406pub struct ToastAnimationState {
407    /// Current animation phase.
408    pub phase: ToastAnimationPhase,
409    /// When the current phase started.
410    pub phase_started: Instant,
411    /// Whether reduced motion is active.
412    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    /// Create a new animation state starting in the Entering phase.
427    pub fn new() -> Self {
428        Self::default()
429    }
430
431    /// Create a state with reduced motion enabled (skips to Visible).
432    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    /// Get the progress within the current phase (0.0 to 1.0).
441    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    /// Transition to the next phase.
450    pub fn transition_to(&mut self, phase: ToastAnimationPhase) {
451        self.phase = phase;
452        self.phase_started = Instant::now();
453    }
454
455    /// Start the exit animation.
456    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    /// Check if the animation has completed (Hidden phase).
465    pub fn is_complete(&self) -> bool {
466        self.phase == ToastAnimationPhase::Hidden
467    }
468
469    /// Update the animation state based on elapsed time.
470    ///
471    /// Returns true if the phase changed.
472    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    /// Calculate the current animation offset.
503    ///
504    /// Returns (dx, dy) offset to apply to the toast position.
505    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    /// Calculate the current opacity (0.0 to 1.0).
539    ///
540    /// Used for fade animations.
541    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/// Configuration for a toast notification.
574#[derive(Debug, Clone)]
575pub struct ToastConfig {
576    /// Position on screen.
577    pub position: ToastPosition,
578    /// Auto-dismiss duration. `None` means persistent until dismissed.
579    pub duration: Option<Duration>,
580    /// Visual style variant.
581    pub style_variant: ToastStyle,
582    /// Maximum width in columns.
583    pub max_width: u16,
584    /// Margin from screen edges.
585    pub margin: u16,
586    /// Whether the toast can be dismissed by the user.
587    pub dismissable: bool,
588    /// Animation configuration.
589    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/// Simplified key event for toast interaction handling.
607///
608/// This is a widget-level abstraction over terminal key events. The hosting
609/// application maps its native key events to these variants before passing
610/// them to `Toast::handle_key`.
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612pub enum KeyEvent {
613    /// Escape key — dismiss the toast.
614    Esc,
615    /// Tab key — cycle focus through action buttons.
616    Tab,
617    /// Enter key — invoke the focused action.
618    Enter,
619    /// Any other key (not consumed by the toast).
620    Other,
621}
622
623/// An interactive action button displayed in a toast.
624///
625/// Actions allow users to respond to a toast (e.g., "Undo", "Retry", "View").
626/// Each action has a display label and a unique identifier used to match
627/// callbacks when the action is invoked.
628///
629/// # Invariants
630///
631/// - `label` must be non-empty after trimming whitespace.
632/// - `id` must be non-empty; it serves as the stable callback key.
633/// - Display width of `label` is bounded by toast `max_width` minus chrome.
634///
635/// # Evidence Ledger
636///
637/// Action focus uses a simple round-robin model: Tab advances focus index
638/// modulo action count. This is deterministic and requires no scoring heuristic.
639/// The decision rule is: `next_focus = (current_focus + 1) % actions.len()`.
640#[derive(Debug, Clone, PartialEq, Eq)]
641pub struct ToastAction {
642    /// Display label for the action button (e.g., "Undo").
643    pub label: String,
644    /// Unique identifier for callback matching.
645    pub id: String,
646}
647
648impl ToastAction {
649    /// Create a new toast action.
650    ///
651    /// # Panics
652    ///
653    /// Panics in debug builds if `label` or `id` is empty after trimming.
654    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    /// Display width of the action button including brackets.
666    ///
667    /// Rendered as `[Label]`, so width = label_width + 2 (brackets).
668    pub fn display_width(&self) -> usize {
669        display_width(self.label.as_str()) + 2 // [ + label + ]
670    }
671}
672
673/// Result of handling a toast interaction event.
674///
675/// Returned by `Toast::handle_key` to indicate what happened.
676#[derive(Debug, Clone, PartialEq, Eq)]
677pub enum ToastEvent {
678    /// No interaction occurred (key not consumed).
679    None,
680    /// The toast was dismissed.
681    Dismissed,
682    /// An action button was invoked. Contains the action ID.
683    Action(String),
684    /// Focus moved between action buttons.
685    FocusChanged,
686}
687
688/// Content of a toast notification.
689#[derive(Debug, Clone)]
690pub struct ToastContent {
691    /// Main message text.
692    pub message: String,
693    /// Optional icon.
694    pub icon: Option<ToastIcon>,
695    /// Optional title.
696    pub title: Option<String>,
697}
698
699impl ToastContent {
700    /// Create new content with just a message.
701    pub fn new(message: impl Into<String>) -> Self {
702        Self {
703            message: message.into(),
704            icon: None,
705            title: None,
706        }
707    }
708
709    /// Set the icon.
710    pub fn with_icon(mut self, icon: ToastIcon) -> Self {
711        self.icon = Some(icon);
712        self
713    }
714
715    /// Set the title.
716    pub fn with_title(mut self, title: impl Into<String>) -> Self {
717        self.title = Some(title.into());
718        self
719    }
720}
721
722/// Internal state tracking for a toast.
723#[derive(Debug, Clone)]
724pub struct ToastState {
725    /// When the toast was created.
726    pub created_at: Instant,
727    /// Whether the toast has been dismissed.
728    pub dismissed: bool,
729    /// Animation state.
730    pub animation: ToastAnimationState,
731    /// Index of the currently focused action, if any.
732    pub focused_action: Option<usize>,
733    /// Whether the auto-dismiss timer is paused (e.g., due to action focus).
734    pub timer_paused: bool,
735    /// When the timer was paused, for calculating credited time.
736    pub pause_started: Option<Instant>,
737    /// Total duration the timer has been paused (accumulated across multiple pauses).
738    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    /// Create a new state with reduced motion enabled.
757    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/// A toast notification widget.
771///
772/// Toasts display transient messages to the user, typically in a corner
773/// of the screen. They can auto-dismiss after a duration or be manually
774/// dismissed.
775///
776/// # Example
777///
778/// ```ignore
779/// let toast = Toast::new("Operation completed")
780///     .icon(ToastIcon::Success)
781///     .position(ToastPosition::TopRight)
782///     .duration(Duration::from_secs(3));
783///
784/// // Render the toast
785/// toast.render(area, frame);
786/// ```
787#[derive(Debug, Clone)]
788pub struct Toast {
789    /// Unique identifier.
790    pub id: ToastId,
791    /// Toast content.
792    pub content: ToastContent,
793    /// Configuration.
794    pub config: ToastConfig,
795    /// Internal state.
796    pub state: ToastState,
797    /// Interactive action buttons (e.g., "Undo", "Retry").
798    pub actions: Vec<ToastAction>,
799    /// Base style override.
800    style: Style,
801    /// Icon style override.
802    icon_style: Style,
803    /// Title style override.
804    title_style: Style,
805    /// Style for action buttons.
806    action_style: Style,
807    /// Style for the focused action button.
808    action_focus_style: Style,
809}
810
811impl Toast {
812    /// Create a new toast with the given message.
813    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    /// Create a toast with a specific ID.
832    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    // --- Builder methods ---
848
849    /// Set the toast icon.
850    pub fn icon(mut self, icon: ToastIcon) -> Self {
851        self.content.icon = Some(icon);
852        self
853    }
854
855    /// Set the toast title.
856    pub fn title(mut self, title: impl Into<String>) -> Self {
857        self.content.title = Some(title.into());
858        self
859    }
860
861    /// Set the toast position.
862    pub fn position(mut self, position: ToastPosition) -> Self {
863        self.config.position = position;
864        self
865    }
866
867    /// Set the auto-dismiss duration.
868    pub fn duration(mut self, duration: Duration) -> Self {
869        self.config.duration = Some(duration);
870        self
871    }
872
873    /// Make the toast persistent (no auto-dismiss).
874    pub fn persistent(mut self) -> Self {
875        self.config.duration = None;
876        self
877    }
878
879    /// Set the style variant.
880    pub fn style_variant(mut self, variant: ToastStyle) -> Self {
881        self.config.style_variant = variant;
882        self
883    }
884
885    /// Set the maximum width.
886    pub fn max_width(mut self, width: u16) -> Self {
887        self.config.max_width = width;
888        self
889    }
890
891    /// Set the margin from screen edges.
892    pub fn margin(mut self, margin: u16) -> Self {
893        self.config.margin = margin;
894        self
895    }
896
897    /// Set whether the toast is dismissable.
898    pub fn dismissable(mut self, dismissable: bool) -> Self {
899        self.config.dismissable = dismissable;
900        self
901    }
902
903    /// Set the base style.
904    pub fn style(mut self, style: Style) -> Self {
905        self.style = style;
906        self
907    }
908
909    /// Set the icon style.
910    pub fn with_icon_style(mut self, style: Style) -> Self {
911        self.icon_style = style;
912        self
913    }
914
915    /// Set the title style.
916    pub fn with_title_style(mut self, style: Style) -> Self {
917        self.title_style = style;
918        self
919    }
920
921    // --- Animation builder methods ---
922
923    /// Set the entrance animation.
924    pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
925        self.config.animation.entrance = animation;
926        self
927    }
928
929    /// Set the exit animation.
930    pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
931        self.config.animation.exit = animation;
932        self
933    }
934
935    /// Set the entrance animation duration.
936    pub fn entrance_duration(mut self, duration: Duration) -> Self {
937        self.config.animation.entrance_duration = duration;
938        self
939    }
940
941    /// Set the exit animation duration.
942    pub fn exit_duration(mut self, duration: Duration) -> Self {
943        self.config.animation.exit_duration = duration;
944        self
945    }
946
947    /// Set the entrance easing function.
948    pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
949        self.config.animation.entrance_easing = easing;
950        self
951    }
952
953    /// Set the exit easing function.
954    pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
955        self.config.animation.exit_easing = easing;
956        self
957    }
958
959    // --- Action builder methods ---
960
961    /// Add a single action button to the toast.
962    pub fn action(mut self, action: ToastAction) -> Self {
963        self.actions.push(action);
964        self
965    }
966
967    /// Set all action buttons at once.
968    pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
969        self.actions = actions;
970        self
971    }
972
973    /// Set the style for action buttons.
974    pub fn with_action_style(mut self, style: Style) -> Self {
975        self.action_style = style;
976        self
977    }
978
979    /// Set the style for the focused action button.
980    pub fn with_action_focus_style(mut self, style: Style) -> Self {
981        self.action_focus_style = style;
982        self
983    }
984
985    /// Disable all animations.
986    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    /// Enable reduced motion mode (skips animations).
997    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    // --- State methods ---
1006
1007    /// Check if the toast has expired based on its duration.
1008    ///
1009    /// Accounts for time spent paused (when actions are focused).
1010    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    /// Check if the toast should be visible.
1027    ///
1028    /// A toast is visible if it's not dismissed, not expired, and not in
1029    /// the Hidden animation phase.
1030    pub fn is_visible(&self) -> bool {
1031        !self.state.dismissed
1032            && !self.is_expired()
1033            && self.state.animation.phase != ToastAnimationPhase::Hidden
1034    }
1035
1036    /// Check if the toast is currently animating.
1037    pub fn is_animating(&self) -> bool {
1038        matches!(
1039            self.state.animation.phase,
1040            ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1041        )
1042    }
1043
1044    /// Dismiss the toast, starting exit animation.
1045    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    /// Dismiss immediately without animation.
1053    pub fn dismiss_immediately(&mut self) {
1054        self.state.dismissed = true;
1055        self.state
1056            .animation
1057            .transition_to(ToastAnimationPhase::Hidden);
1058    }
1059
1060    /// Update the animation state. Call this each frame.
1061    ///
1062    /// Returns true if the animation phase changed.
1063    pub fn tick_animation(&mut self) -> bool {
1064        self.state.animation.tick(&self.config.animation)
1065    }
1066
1067    /// Get the current animation phase.
1068    pub fn animation_phase(&self) -> ToastAnimationPhase {
1069        self.state.animation.phase
1070    }
1071
1072    /// Get the current animation offset for rendering.
1073    ///
1074    /// Returns (dx, dy) offset to apply to the position.
1075    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    /// Get the current opacity for rendering (0.0 to 1.0).
1083    pub fn animation_opacity(&self) -> f64 {
1084        self.state.animation.current_opacity(&self.config.animation)
1085    }
1086
1087    /// Get the remaining time before auto-dismiss.
1088    ///
1089    /// Accounts for paused time.
1090    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    // --- Interaction methods ---
1105
1106    /// Handle a key event for toast interaction.
1107    ///
1108    /// Supported keys:
1109    /// - `Esc`: Dismiss the toast (if dismissable).
1110    /// - `Tab`: Cycle focus through action buttons (round-robin).
1111    /// - `Enter`: Invoke the focused action. Returns `ToastEvent::Action(id)`.
1112    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    /// Pause the auto-dismiss timer.
1156    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    /// Resume the auto-dismiss timer.
1164    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    /// Clear action focus and resume the timer.
1174    pub fn clear_focus(&mut self) {
1175        self.state.focused_action = None;
1176        self.resume_timer();
1177    }
1178
1179    /// Check whether any action is currently focused.
1180    pub fn has_focus(&self) -> bool {
1181        self.state.focused_action.is_some()
1182    }
1183
1184    /// Get the currently focused action, if any.
1185    pub fn focused_action(&self) -> Option<&ToastAction> {
1186        self.state
1187            .focused_action
1188            .and_then(|idx| self.actions.get(idx))
1189    }
1190
1191    /// Calculate the toast dimensions based on content.
1192    pub fn calculate_dimensions(&self) -> (u16, u16) {
1193        let max_width = self.config.max_width as usize;
1194
1195        // Calculate content width
1196        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); // icon + space
1205        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        // Content width is max of title and message (plus icon)
1214        let mut content_width = (icon_width + message_width).max(title_width);
1215
1216        // Account for actions row width: [Label] [Label] with spaces between
1217        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); // spaces between buttons
1224            content_width = content_width.max(actions_width);
1225        }
1226
1227        // Add padding (1 char each side) and border (1 char each side)
1228        let total_width = content_width.saturating_add(4).min(max_width);
1229
1230        // Height: border (2) + optional title (1) + message (1) + optional actions (1)
1231        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        // Calculate actual render area (use provided area or calculate from content)
1259        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; // Too small to render
1265        }
1266
1267        let render_area = Rect::new(area.x, area.y, width, height);
1268
1269        // Apply base style to the entire area
1270        if deg.apply_styling() {
1271            set_style_area(&mut frame.buffer, render_area, self.style);
1272        }
1273
1274        // Draw border
1275        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        // Top border
1285        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        // Bottom border
1310        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        // Side borders
1336        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        // Draw content
1355        let content_x = render_area.x + 1; // After left border
1356        let content_width = width.saturating_sub(2); // Minus borders
1357        let mut content_y = render_area.y + 1;
1358
1359        // Draw title if present
1360        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        // Draw icon and message
1384        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        // Draw message
1418        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        // Draw action buttons if present
1433        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                // Space between buttons
1452                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        // Toasts are informational, not essential
1468        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        // Top-left
1548        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        // Top-right
1559        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); // 49
1567        assert_eq!(y, 1);
1568
1569        // Bottom-right
1570        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); // 20
1579
1580        // Top-center
1581        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); // 25
1589        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        // ASCII fallbacks
1601        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        // "Short" = 5 chars + 4 (padding+border) = 9
1610        assert_eq!(w, 9);
1611        assert_eq!(h, 3); // No title
1612
1613        let toast_with_title = Toast::new("Message").title("Title");
1614        let (_w, h) = toast_with_title.calculate_dimensions();
1615        assert_eq!(h, 4); // With title
1616    }
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        // Check border corners
1644        assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); // ┌
1645        assert!(frame.buffer.get(1, 1).is_some()); // Content area exists
1646    }
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        // Icon should be at position (1, 1) - inside border
1657        let icon_cell = cell_at(&frame, 1, 1);
1658        if let Some(ch) = icon_cell.content.as_char() {
1659            assert_eq!(ch, '\u{2713}'); // ✓
1660        } 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        // Title at row 1, message at row 2
1676        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); // Should not panic
1687    }
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); // Should not render (too small)
1696    }
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        // Save original state
1707        let original = cell_at(&frame, 0, 0).content.as_char();
1708
1709        toast.render(area, &mut frame);
1710
1711        // Buffer should be unchanged (dismissed toast doesn't render)
1712        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    // --- Animation Tests ---
1755
1756    #[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        // SlideFromTop: starts above, ends at (0, 0)
1815        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1816        assert_eq!(dx, 0);
1817        assert_eq!(dy, -(height as i16));
1818
1819        // At progress 0.0, should be at initial offset
1820        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        // At progress 1.0, should be at (0, 0)
1825        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1826        assert_eq!(dx, 0);
1827        assert_eq!(dy, 0);
1828
1829        // SlideFromRight: starts to the right
1830        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        // SlideOut reverses entrance direction
1842        let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1843        assert_eq!(dx, -(width as i16)); // Opposite of SlideFromRight
1844        assert_eq!(dy, 0);
1845
1846        // At progress 0.0, should be at (0, 0)
1847        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        // At progress 1.0, should be at final offset
1853        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        // Linear: t = t
1862        assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
1863
1864        // EaseOut at 0.5 should be > 0.5 (decelerating)
1865        assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
1866
1867        // EaseIn at 0.5 should be < 0.5 (accelerating)
1868        assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
1869
1870        // All should be 0 at 0 and 1 at 1
1871        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        // Just created, progress should be very small
1887        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        // Zero duration should return 1.0 (complete)
1898        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        // First set to visible phase
1906        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()); // Starts in Entering phase
1929
1930        let toast_visible = Toast::new("Test").no_animation();
1931        assert!(!toast_visible.is_animating()); // No animation = Visible phase
1932    }
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        // At progress 0, opacity should be 0
1947        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        // At progress 1 (Visible phase), opacity should be 1
1952        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        // Should have positive dx (sliding from right)
1986        assert!(dx > 0, "Should have positive x offset at start");
1987        assert_eq!(dy, 0);
1988    }
1989
1990    // ── Interactive Toast Action tests ─────────────────────────────────
1991
1992    #[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        // [OK] = 4 chars
2023        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        // First tab to focus
2039        toast.handle_key(KeyEvent::Tab);
2040        assert!(toast.has_focus());
2041        // Esc clears focus rather than dismissing
2042        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        // Wraps around
2063        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); // focus action 0
2081        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        // Double pause should not panic
2131        toast.pause_timer();
2132        toast.pause_timer();
2133        assert!(toast.state.timer_paused);
2134        // Double resume should not panic
2135        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        // Sleep past the duration while paused
2172        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        // Not expired yet because paused time is subtracted
2179        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        // Without actions: 3 (border + message + border)
2192        // With actions: 4 (border + message + actions + border)
2193        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        // border + title + message + actions + border = 5
2204        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        // [LongButtonLabel] = 18 chars, plus 4 for borders/padding = 22
2214        // "Hi" = 2 chars + 4 = 6, so actions width dominates
2215        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); // focus first action
2237
2238        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); // focus 0
2285        toast.handle_key(KeyEvent::Tab); // focus 1
2286        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}