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 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/// 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    fn offset_from_dimension(value: u16) -> i16 {
191        i16::try_from(value).unwrap_or(i16::MAX)
192    }
193
194    /// Get the initial offset for this entrance animation.
195    ///
196    /// Returns (dx, dy) offset in cells from the final position.
197    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    /// Calculate the offset at a given progress (0.0 to 1.0).
210    ///
211    /// Progress of 0.0 = initial offset, 1.0 = no offset.
212    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    /// Check if this animation affects position (vs. just opacity).
227    pub fn affects_position(self) -> bool {
228        !matches!(self, Self::FadeIn | Self::None)
229    }
230}
231
232/// Exit animation type.
233///
234/// Determines how the toast disappears from screen.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum ToastExitAnimation {
237    /// Fade out (opacity transition).
238    #[default]
239    FadeOut,
240    /// Slide out in the reverse of entrance direction.
241    SlideOut,
242    /// Slide out to the specified edge.
243    SlideToTop,
244    SlideToRight,
245    SlideToBottom,
246    SlideToLeft,
247    /// No animation (instant disappear).
248    None,
249}
250
251impl ToastExitAnimation {
252    /// Get the final offset for this exit animation.
253    ///
254    /// Returns (dx, dy) offset in cells from the starting position.
255    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                // Reverse of entrance direction
266                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    /// Calculate the offset at a given progress (0.0 to 1.0).
278    ///
279    /// Progress of 0.0 = no offset, 1.0 = final offset.
280    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    /// Check if this animation affects position (vs. just opacity).
296    pub fn affects_position(self) -> bool {
297        !matches!(self, Self::FadeOut | Self::None)
298    }
299}
300
301/// Easing function for animations.
302///
303/// Simplified subset of easing curves for toast animations.
304/// For the full set, see `ftui_extras::text_effects::Easing`.
305#[derive(Debug, Clone, Copy, PartialEq, Default)]
306pub enum ToastEasing {
307    /// Linear interpolation.
308    Linear,
309    /// Smooth ease-out (decelerating).
310    #[default]
311    EaseOut,
312    /// Smooth ease-in (accelerating).
313    EaseIn,
314    /// Smooth S-curve.
315    EaseInOut,
316    /// Bouncy effect.
317    Bounce,
318}
319
320impl ToastEasing {
321    /// Apply the easing function to a progress value (0.0 to 1.0).
322    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/// Animation configuration for a toast.
361#[derive(Debug, Clone)]
362pub struct ToastAnimationConfig {
363    /// Entrance animation type.
364    pub entrance: ToastEntranceAnimation,
365    /// Exit animation type.
366    pub exit: ToastExitAnimation,
367    /// Duration of entrance animation.
368    pub entrance_duration: Duration,
369    /// Duration of exit animation.
370    pub exit_duration: Duration,
371    /// Easing function for entrance.
372    pub entrance_easing: ToastEasing,
373    /// Easing function for exit.
374    pub exit_easing: ToastEasing,
375    /// Whether to respect reduced-motion preference.
376    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    /// Create a config with no animations.
395    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    /// Check if animations are effectively disabled.
406    pub fn is_disabled(&self) -> bool {
407        matches!(self.entrance, ToastEntranceAnimation::None)
408            && matches!(self.exit, ToastExitAnimation::None)
409    }
410}
411
412/// Tracks the animation state for a toast.
413#[derive(Debug, Clone)]
414pub struct ToastAnimationState {
415    /// Current animation phase.
416    pub phase: ToastAnimationPhase,
417    /// When the current phase started.
418    pub phase_started: Instant,
419    /// Whether reduced motion is active.
420    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    /// Create a new animation state starting in the Entering phase.
435    pub fn new() -> Self {
436        Self::default()
437    }
438
439    /// Create a state with reduced motion enabled (skips to Visible).
440    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    /// Get the progress within the current phase (0.0 to 1.0).
449    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    /// Transition to the next phase.
458    pub fn transition_to(&mut self, phase: ToastAnimationPhase) {
459        self.phase = phase;
460        self.phase_started = Instant::now();
461    }
462
463    /// Start the exit animation.
464    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    /// Check if the animation has completed (Hidden phase).
473    pub fn is_complete(&self) -> bool {
474        self.phase == ToastAnimationPhase::Hidden
475    }
476
477    /// Update the animation state based on elapsed time.
478    ///
479    /// Returns true if the phase changed.
480    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    /// Calculate the current animation offset.
511    ///
512    /// Returns (dx, dy) offset to apply to the toast position.
513    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    /// Calculate the current opacity (0.0 to 1.0).
547    ///
548    /// Used for fade animations.
549    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/// Configuration for a toast notification.
582#[derive(Debug, Clone)]
583pub struct ToastConfig {
584    /// Position on screen.
585    pub position: ToastPosition,
586    /// Auto-dismiss duration. `None` means persistent until dismissed.
587    pub duration: Option<Duration>,
588    /// Whether the duration/persistence policy was explicitly configured by
589    /// the caller instead of inherited from a queue-level default.
590    pub duration_explicit: bool,
591    /// Visual style variant.
592    pub style_variant: ToastStyle,
593    /// Maximum width in columns.
594    pub max_width: u16,
595    /// Margin from screen edges.
596    pub margin: u16,
597    /// Whether the toast can be dismissed by the user.
598    pub dismissable: bool,
599    /// Animation configuration.
600    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/// Simplified key event for toast interaction handling.
619///
620/// This is a widget-level abstraction over terminal key events. The hosting
621/// application maps its native key events to these variants before passing
622/// them to `Toast::handle_key`.
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub enum KeyEvent {
625    /// Escape key — dismiss the toast.
626    Esc,
627    /// Tab key — cycle focus through action buttons.
628    Tab,
629    /// Enter key — invoke the focused action.
630    Enter,
631    /// Any other key (not consumed by the toast).
632    Other,
633}
634
635/// An interactive action button displayed in a toast.
636///
637/// Actions allow users to respond to a toast (e.g., "Undo", "Retry", "View").
638/// Each action has a display label and a unique identifier used to match
639/// callbacks when the action is invoked.
640///
641/// # Invariants
642///
643/// - `label` must be non-empty after trimming whitespace.
644/// - `id` must be non-empty; it serves as the stable callback key.
645/// - Display width of `label` is bounded by toast `max_width` minus chrome.
646///
647/// # Evidence Ledger
648///
649/// Action focus uses a simple round-robin model: Tab advances focus index
650/// modulo action count. This is deterministic and requires no scoring heuristic.
651/// The decision rule is: `next_focus = (current_focus + 1) % actions.len()`.
652#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct ToastAction {
654    /// Display label for the action button (e.g., "Undo").
655    pub label: String,
656    /// Unique identifier for callback matching.
657    pub id: String,
658}
659
660impl ToastAction {
661    /// Create a new toast action.
662    ///
663    /// # Panics
664    ///
665    /// Panics in debug builds if `label` or `id` is empty after trimming.
666    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    /// Display width of the action button including brackets.
678    ///
679    /// Rendered as `[Label]`, so width = label_width + 2 (brackets).
680    pub fn display_width(&self) -> usize {
681        display_width(self.label.as_str()) + 2 // [ + label + ]
682    }
683}
684
685/// Result of handling a toast interaction event.
686///
687/// Returned by `Toast::handle_key` to indicate what happened.
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub enum ToastEvent {
690    /// No interaction occurred (key not consumed).
691    None,
692    /// The toast was dismissed.
693    Dismissed,
694    /// An action button was invoked. Contains the action ID.
695    Action(String),
696    /// Focus moved between action buttons.
697    FocusChanged,
698}
699
700/// Content of a toast notification.
701#[derive(Debug, Clone)]
702pub struct ToastContent {
703    /// Main message text.
704    pub message: String,
705    /// Optional icon.
706    pub icon: Option<ToastIcon>,
707    /// Optional title.
708    pub title: Option<String>,
709}
710
711impl ToastContent {
712    /// Create new content with just a message.
713    pub fn new(message: impl Into<String>) -> Self {
714        Self {
715            message: message.into(),
716            icon: None,
717            title: None,
718        }
719    }
720
721    /// Set the icon.
722    #[must_use]
723    pub fn with_icon(mut self, icon: ToastIcon) -> Self {
724        self.icon = Some(icon);
725        self
726    }
727
728    /// Set the title.
729    #[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/// Internal state tracking for a toast.
737#[derive(Debug, Clone)]
738pub struct ToastState {
739    /// When the toast was created.
740    pub created_at: Instant,
741    /// Whether the toast has been dismissed.
742    pub dismissed: bool,
743    /// Animation state.
744    pub animation: ToastAnimationState,
745    /// Index of the currently focused action, if any.
746    pub focused_action: Option<usize>,
747    /// Whether the auto-dismiss timer is paused (e.g., due to action focus).
748    pub timer_paused: bool,
749    /// When the timer was paused, for calculating credited time.
750    pub pause_started: Option<Instant>,
751    /// Total duration the timer has been paused (accumulated across multiple pauses).
752    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    /// Create a new state with reduced motion enabled.
771    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/// A toast notification widget.
785///
786/// Toasts display transient messages to the user, typically in a corner
787/// of the screen. They can auto-dismiss after a duration or be manually
788/// dismissed.
789///
790/// # Example
791///
792/// ```ignore
793/// let toast = Toast::new("Operation completed")
794///     .icon(ToastIcon::Success)
795///     .position(ToastPosition::TopRight)
796///     .duration(Duration::from_secs(3));
797///
798/// // Render the toast
799/// toast.render(area, frame);
800/// ```
801#[derive(Debug, Clone)]
802pub struct Toast {
803    /// Unique identifier.
804    pub id: ToastId,
805    /// Toast content.
806    pub content: ToastContent,
807    /// Configuration.
808    pub config: ToastConfig,
809    /// Internal state.
810    pub state: ToastState,
811    /// Interactive action buttons (e.g., "Undo", "Retry").
812    pub actions: Vec<ToastAction>,
813    /// Base style override.
814    style: Style,
815    /// Icon style override.
816    icon_style: Style,
817    /// Title style override.
818    title_style: Style,
819    /// Style for action buttons.
820    action_style: Style,
821    /// Style for the focused action button.
822    action_focus_style: Style,
823}
824
825impl Toast {
826    /// Create a new toast with the given message.
827    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    /// Create a toast with a specific ID.
846    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    // --- Builder methods ---
862
863    /// Set the toast icon.
864    #[must_use]
865    pub fn icon(mut self, icon: ToastIcon) -> Self {
866        self.content.icon = Some(icon);
867        self
868    }
869
870    /// Set the toast title.
871    #[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    /// Set the toast position.
878    #[must_use]
879    pub fn position(mut self, position: ToastPosition) -> Self {
880        self.config.position = position;
881        self
882    }
883
884    /// Set the auto-dismiss duration.
885    #[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    /// Make the toast persistent (no auto-dismiss).
893    #[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    /// Set the style variant.
901    #[must_use]
902    pub fn style_variant(mut self, variant: ToastStyle) -> Self {
903        self.config.style_variant = variant;
904        self
905    }
906
907    /// Set the maximum width.
908    #[must_use]
909    pub fn max_width(mut self, width: u16) -> Self {
910        self.config.max_width = width;
911        self
912    }
913
914    /// Set the margin from screen edges.
915    #[must_use]
916    pub fn margin(mut self, margin: u16) -> Self {
917        self.config.margin = margin;
918        self
919    }
920
921    /// Set whether the toast is dismissable.
922    #[must_use]
923    pub fn dismissable(mut self, dismissable: bool) -> Self {
924        self.config.dismissable = dismissable;
925        self
926    }
927
928    /// Set the base style.
929    #[must_use]
930    pub fn style(mut self, style: Style) -> Self {
931        self.style = style;
932        self
933    }
934
935    /// Set the icon style.
936    #[must_use]
937    pub fn with_icon_style(mut self, style: Style) -> Self {
938        self.icon_style = style;
939        self
940    }
941
942    /// Set the title style.
943    #[must_use]
944    pub fn with_title_style(mut self, style: Style) -> Self {
945        self.title_style = style;
946        self
947    }
948
949    // --- Animation builder methods ---
950
951    /// Set the entrance animation.
952    #[must_use]
953    pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
954        self.config.animation.entrance = animation;
955        self
956    }
957
958    /// Set the exit animation.
959    #[must_use]
960    pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
961        self.config.animation.exit = animation;
962        self
963    }
964
965    /// Set the entrance animation duration.
966    #[must_use]
967    pub fn entrance_duration(mut self, duration: Duration) -> Self {
968        self.config.animation.entrance_duration = duration;
969        self
970    }
971
972    /// Set the exit animation duration.
973    #[must_use]
974    pub fn exit_duration(mut self, duration: Duration) -> Self {
975        self.config.animation.exit_duration = duration;
976        self
977    }
978
979    /// Set the entrance easing function.
980    #[must_use]
981    pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
982        self.config.animation.entrance_easing = easing;
983        self
984    }
985
986    /// Set the exit easing function.
987    #[must_use]
988    pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
989        self.config.animation.exit_easing = easing;
990        self
991    }
992
993    // --- Action builder methods ---
994
995    /// Add a single action button to the toast.
996    #[must_use]
997    pub fn action(mut self, action: ToastAction) -> Self {
998        self.actions.push(action);
999        self
1000    }
1001
1002    /// Set all action buttons at once.
1003    #[must_use]
1004    pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
1005        self.actions = actions;
1006        self
1007    }
1008
1009    /// Set the style for action buttons.
1010    #[must_use]
1011    pub fn with_action_style(mut self, style: Style) -> Self {
1012        self.action_style = style;
1013        self
1014    }
1015
1016    /// Set the style for the focused action button.
1017    #[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    /// Disable all animations.
1024    #[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    /// Enable reduced motion mode (skips animations).
1036    #[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    // --- State methods ---
1046
1047    /// Check if the toast has expired based on its duration.
1048    ///
1049    /// Accounts for time spent paused (when actions are focused).
1050    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    /// Check if the toast should be visible.
1061    ///
1062    /// A toast is visible if it's not in the Hidden animation phase.
1063    #[inline]
1064    pub fn is_visible(&self) -> bool {
1065        self.state.animation.phase != ToastAnimationPhase::Hidden
1066    }
1067
1068    /// Check if the toast is currently animating.
1069    pub fn is_animating(&self) -> bool {
1070        matches!(
1071            self.state.animation.phase,
1072            ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1073        )
1074    }
1075
1076    /// Dismiss the toast, starting exit animation.
1077    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    /// Dismiss immediately without animation.
1085    pub fn dismiss_immediately(&mut self) {
1086        self.state.dismissed = true;
1087        self.state
1088            .animation
1089            .transition_to(ToastAnimationPhase::Hidden);
1090    }
1091
1092    /// Update the animation state. Call this each frame.
1093    ///
1094    /// Returns true if the animation phase changed.
1095    pub fn tick_animation(&mut self) -> bool {
1096        self.state.animation.tick(&self.config.animation)
1097    }
1098
1099    /// Get the current animation phase.
1100    pub fn animation_phase(&self) -> ToastAnimationPhase {
1101        self.state.animation.phase
1102    }
1103
1104    /// Get the current animation offset for rendering.
1105    ///
1106    /// Returns (dx, dy) offset to apply to the position.
1107    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    /// Get the current opacity for rendering (0.0 to 1.0).
1115    pub fn animation_opacity(&self) -> f64 {
1116        self.state.animation.current_opacity(&self.config.animation)
1117    }
1118
1119    /// Get the remaining time before auto-dismiss.
1120    ///
1121    /// Accounts for paused time.
1122    #[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    // --- Interaction methods ---
1132
1133    /// Handle a key event for toast interaction.
1134    ///
1135    /// Supported keys:
1136    /// - `Esc`: Dismiss the toast (if dismissable).
1137    /// - `Tab`: Cycle focus through action buttons (round-robin).
1138    /// - `Enter`: Invoke the focused action. Returns `ToastEvent::Action(id)`.
1139    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    /// Pause the auto-dismiss timer.
1183    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    /// Resume the auto-dismiss timer.
1191    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    /// Clear action focus and resume the timer.
1204    pub fn clear_focus(&mut self) {
1205        self.state.focused_action = None;
1206        self.resume_timer();
1207    }
1208
1209    /// Check whether any action is currently focused.
1210    pub fn has_focus(&self) -> bool {
1211        self.state.focused_action.is_some()
1212    }
1213
1214    /// Get the currently focused action, if any.
1215    #[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    /// Calculate the toast dimensions based on content.
1233    pub fn calculate_dimensions(&self) -> (u16, u16) {
1234        let max_width = self.config.max_width as usize;
1235
1236        // Calculate content width
1237        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); // icon + space
1246        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        // Content width is max of title and message (plus icon)
1255        let mut content_width = (icon_width + message_width).max(title_width);
1256
1257        // Account for actions row width: [Label] [Label] with spaces between
1258        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); // spaces between buttons
1265            content_width = content_width.max(actions_width);
1266        }
1267
1268        // Add padding (1 char each side) and border (1 char each side)
1269        let total_width = content_width.saturating_add(4).min(max_width);
1270
1271        // Height: border (2) + optional title (1) + message (1) + optional actions (1)
1272        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        // Calculate actual render area (use provided area or calculate from content)
1298        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; // Too small to render
1304        }
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        // Draw border
1326        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        // Top border
1336        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        // Bottom border
1361        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        // Side borders
1385        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        // Draw content
1402        let content_x = render_area.x + 1; // After left border
1403        let content_width = width.saturating_sub(2); // Minus borders
1404        let mut content_y = render_area.y + 1;
1405
1406        // Draw title if present
1407        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        // Draw icon and message
1431        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        // Draw message
1465        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        // Draw action buttons if present
1480        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                // Space between buttons
1499                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        // Toasts are informational, not essential
1515        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        // Top-left
1611        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        // Top-right
1622        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); // 49
1630        assert_eq!(y, 1);
1631
1632        // Bottom-right
1633        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); // 20
1642
1643        // Top-center
1644        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); // 25
1652        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        // ASCII fallbacks
1664        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        // "Short" = 5 chars + 4 (padding+border) = 9
1673        assert_eq!(w, 9);
1674        assert_eq!(h, 3); // No title
1675
1676        let toast_with_title = Toast::new("Message").title("Title");
1677        let (_w, h) = toast_with_title.calculate_dimensions();
1678        assert_eq!(h, 4); // With title
1679    }
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        // Check border corners
1707        assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); // ┌
1708        assert!(frame.buffer.get(1, 1).is_some()); // Content area exists
1709    }
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        // Icon should be at position (1, 1) - inside border
1720        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        // Title at row 1, message at row 2
1740        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); // Should not panic
1751    }
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); // Should not render (too small)
1760    }
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    // --- Animation Tests ---
1895
1896    #[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        // SlideFromTop: starts above, ends at (0, 0)
1955        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1956        assert_eq!(dx, 0);
1957        assert_eq!(dy, -(height as i16));
1958
1959        // At progress 0.0, should be at initial offset
1960        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        // At progress 1.0, should be at (0, 0)
1965        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1966        assert_eq!(dx, 0);
1967        assert_eq!(dy, 0);
1968
1969        // SlideFromRight: starts to the right
1970        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        // SlideOut reverses entrance direction
1982        let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1983        assert_eq!(dx, -(width as i16)); // Opposite of SlideFromRight
1984        assert_eq!(dy, 0);
1985
1986        // At progress 0.0, should be at (0, 0)
1987        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        // At progress 1.0, should be at final offset
1993        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        // Linear: t = t
2002        assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
2003
2004        // EaseOut at 0.5 should be > 0.5 (decelerating)
2005        assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
2006
2007        // EaseIn at 0.5 should be < 0.5 (accelerating)
2008        assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
2009
2010        // All should be 0 at 0 and 1 at 1
2011        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        // Just created, progress should be very small
2027        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        // Zero duration should return 1.0 (complete)
2038        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        // First set to visible phase
2046        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()); // Starts in Entering phase
2069
2070        let toast_visible = Toast::new("Test").no_animation();
2071        assert!(!toast_visible.is_animating()); // No animation = Visible phase
2072    }
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        // At progress 0, opacity should be 0
2087        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        // At progress 1 (Visible phase), opacity should be 1
2092        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        // Should have positive dx (sliding from right)
2126        assert!(dx > 0, "Should have positive x offset at start");
2127        assert_eq!(dy, 0);
2128    }
2129
2130    // ── Interactive Toast Action tests ─────────────────────────────────
2131
2132    #[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        // [OK] = 4 chars
2163        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        // First tab to focus
2179        toast.handle_key(KeyEvent::Tab);
2180        assert!(toast.has_focus());
2181        // Esc clears focus rather than dismissing
2182        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        // Wraps around
2203        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); // focus action 0
2221        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        // Double pause should not panic
2271        toast.pause_timer();
2272        toast.pause_timer();
2273        assert!(toast.state.timer_paused);
2274        // Double resume should not panic
2275        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        // Sleep past the duration while paused
2335        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        // Not expired yet because paused time is subtracted
2342        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        // Without actions: 3 (border + message + border)
2355        // With actions: 4 (border + message + actions + border)
2356        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        // border + title + message + actions + border = 5
2367        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        // [LongButtonLabel] = 18 chars, plus 4 for borders/padding = 22
2377        // "Hi" = 2 chars + 4 = 6, so actions width dominates
2378        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); // focus first action
2400
2401        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); // focus 0
2448        toast.handle_key(KeyEvent::Tab); // focus 1
2449        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    // =========================================================================
2465    // Position edge cases (bd-9vqk6)
2466    // =========================================================================
2467
2468    #[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); // 20
2473    }
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); // 30
2479        assert_eq!(y, 24 - 3 - 1); // 20
2480    }
2481
2482    #[test]
2483    fn position_toast_wider_than_terminal_saturates() {
2484        // Toast is wider than the terminal — x should saturate to 0, not wrap
2485        let (x, y) = ToastPosition::TopRight.calculate_position(20, 10, 30, 3, 1);
2486        assert_eq!(x, 0); // 20 - 30 = saturates to 0, then - 1 = still 0
2487        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); // 3 - 10 saturates to 0, then - 1 = still 0
2505    }
2506
2507    // =========================================================================
2508    // ToastIcon edge cases (bd-9vqk6)
2509    // =========================================================================
2510
2511    #[test]
2512    fn icon_custom_non_ascii_falls_back_to_star() {
2513        let icon = ToastIcon::Custom('\u{1F525}'); // 🔥
2514        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    // =========================================================================
2532    // Default trait coverage (bd-9vqk6)
2533    // =========================================================================
2534
2535    #[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    // =========================================================================
2574    // Entrance animation all variants (bd-9vqk6)
2575    // =========================================================================
2576
2577    #[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); // starts below
2582    }
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        // Below 0 should clamp
2608        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(-0.5, 20, 5);
2609        assert_eq!(dx, 0);
2610        assert_eq!(dy, -5); // Same as progress 0.0
2611
2612        // Above 1 should clamp
2613        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(2.0, 20, 5);
2614        assert_eq!(dx, 0);
2615        assert_eq!(dy, 0); // Same as progress 1.0
2616    }
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); // Half of width
2622        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    // =========================================================================
2642    // Exit animation all variants (bd-9vqk6)
2643    // =========================================================================
2644
2645    #[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)); // Clamped to 0.0
2698
2699        let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(5.0, 20, 5, entrance);
2700        assert_eq!((dx, dy), (0, -5)); // Clamped to 1.0
2701    }
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    // =========================================================================
2721    // Easing function edge cases (bd-9vqk6)
2722    // =========================================================================
2723
2724    #[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        // Sample all four branches of the bounce function
2780        let t1 = 0.2 / d1; // first branch
2781        let t2 = 1.5 / d1; // second branch
2782        let t3 = 2.3 / d1; // third branch
2783        let t4 = 2.7 / d1; // fourth branch
2784
2785        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    // =========================================================================
2797    // Animation state transitions (bd-9vqk6)
2798    // =========================================================================
2799
2800    #[test]
2801    fn animation_state_tick_entering_to_visible() {
2802        let config = ToastAnimationConfig {
2803            entrance_duration: Duration::ZERO, // Instant transition
2804            ..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    // =========================================================================
2867    // Animation offset and opacity in all phases (bd-9vqk6)
2868    // =========================================================================
2869
2870    #[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        // Non-FadeOut exit keeps opacity at 1.0
2937        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        // Non-FadeIn entrance keeps opacity at 1.0
2949        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2950    }
2951
2952    // =========================================================================
2953    // Toast API coverage (bd-9vqk6)
2954    // =========================================================================
2955
2956    #[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(); // Should not change phase again
3006        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    // =========================================================================
3038    // ToastConfig defaults (bd-9vqk6)
3039    // =========================================================================
3040
3041    #[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    // =========================================================================
3054    // ToastAnimationConfig (bd-9vqk6)
3055    // =========================================================================
3056
3057    #[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    // =========================================================================
3074    // ToastId and trait coverage (bd-9vqk6)
3075    // =========================================================================
3076
3077    #[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)); // Duplicate
3084        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; // Copy
3107        assert_eq!(key, copy);
3108        let dbg = format!("{:?}", key);
3109        assert!(dbg.contains("Tab"), "Debug: {dbg}");
3110    }
3111
3112    // =========================================================================
3113    // Tick with reduced motion in entering phase (bd-9vqk6)
3114    // =========================================================================
3115
3116    #[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        // With reduced_motion, entering duration is treated as ZERO → immediate transition
3125        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}