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    /// Get the initial offset for this entrance animation.
191    ///
192    /// Returns (dx, dy) offset in cells from the final position.
193    pub fn initial_offset(self, toast_width: u16, toast_height: u16) -> (i16, i16) {
194        match self {
195            Self::SlideFromTop => (0, -(toast_height as i16)),
196            Self::SlideFromRight => (toast_width as i16, 0),
197            Self::SlideFromBottom => (0, toast_height as i16),
198            Self::SlideFromLeft => (-(toast_width as i16), 0),
199            Self::FadeIn | Self::None => (0, 0),
200        }
201    }
202
203    /// Calculate the offset at a given progress (0.0 to 1.0).
204    ///
205    /// Progress of 0.0 = initial offset, 1.0 = no offset.
206    pub fn offset_at_progress(
207        self,
208        progress: f64,
209        toast_width: u16,
210        toast_height: u16,
211    ) -> (i16, i16) {
212        let (dx, dy) = self.initial_offset(toast_width, toast_height);
213        let inv_progress = 1.0 - progress.clamp(0.0, 1.0);
214        (
215            (dx as f64 * inv_progress).round() as i16,
216            (dy as f64 * inv_progress).round() as i16,
217        )
218    }
219
220    /// Check if this animation affects position (vs. just opacity).
221    pub fn affects_position(self) -> bool {
222        !matches!(self, Self::FadeIn | Self::None)
223    }
224}
225
226/// Exit animation type.
227///
228/// Determines how the toast disappears from screen.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub enum ToastExitAnimation {
231    /// Fade out (opacity transition).
232    #[default]
233    FadeOut,
234    /// Slide out in the reverse of entrance direction.
235    SlideOut,
236    /// Slide out to the specified edge.
237    SlideToTop,
238    SlideToRight,
239    SlideToBottom,
240    SlideToLeft,
241    /// No animation (instant disappear).
242    None,
243}
244
245impl ToastExitAnimation {
246    /// Get the final offset for this exit animation.
247    ///
248    /// Returns (dx, dy) offset in cells from the starting position.
249    pub fn final_offset(
250        self,
251        toast_width: u16,
252        toast_height: u16,
253        entrance: ToastEntranceAnimation,
254    ) -> (i16, i16) {
255        match self {
256            Self::SlideOut => {
257                // Reverse of entrance direction
258                let (dx, dy) = entrance.initial_offset(toast_width, toast_height);
259                (-dx, -dy)
260            }
261            Self::SlideToTop => (0, -(toast_height as i16)),
262            Self::SlideToRight => (toast_width as i16, 0),
263            Self::SlideToBottom => (0, toast_height as i16),
264            Self::SlideToLeft => (-(toast_width as i16), 0),
265            Self::FadeOut | Self::None => (0, 0),
266        }
267    }
268
269    /// Calculate the offset at a given progress (0.0 to 1.0).
270    ///
271    /// Progress of 0.0 = no offset, 1.0 = final offset.
272    pub fn offset_at_progress(
273        self,
274        progress: f64,
275        toast_width: u16,
276        toast_height: u16,
277        entrance: ToastEntranceAnimation,
278    ) -> (i16, i16) {
279        let (dx, dy) = self.final_offset(toast_width, toast_height, entrance);
280        let p = progress.clamp(0.0, 1.0);
281        (
282            (dx as f64 * p).round() as i16,
283            (dy as f64 * p).round() as i16,
284        )
285    }
286
287    /// Check if this animation affects position (vs. just opacity).
288    pub fn affects_position(self) -> bool {
289        !matches!(self, Self::FadeOut | Self::None)
290    }
291}
292
293/// Easing function for animations.
294///
295/// Simplified subset of easing curves for toast animations.
296/// For the full set, see `ftui_extras::text_effects::Easing`.
297#[derive(Debug, Clone, Copy, PartialEq, Default)]
298pub enum ToastEasing {
299    /// Linear interpolation.
300    Linear,
301    /// Smooth ease-out (decelerating).
302    #[default]
303    EaseOut,
304    /// Smooth ease-in (accelerating).
305    EaseIn,
306    /// Smooth S-curve.
307    EaseInOut,
308    /// Bouncy effect.
309    Bounce,
310}
311
312impl ToastEasing {
313    /// Apply the easing function to a progress value (0.0 to 1.0).
314    pub fn apply(self, t: f64) -> f64 {
315        let t = t.clamp(0.0, 1.0);
316        match self {
317            Self::Linear => t,
318            Self::EaseOut => {
319                let inv = 1.0 - t;
320                1.0 - inv * inv * inv
321            }
322            Self::EaseIn => t * t * t,
323            Self::EaseInOut => {
324                if t < 0.5 {
325                    4.0 * t * t * t
326                } else {
327                    let inv = -2.0 * t + 2.0;
328                    1.0 - inv * inv * inv / 2.0
329                }
330            }
331            Self::Bounce => {
332                let n1 = 7.5625;
333                let d1 = 2.75;
334                let mut t = t;
335                if t < 1.0 / d1 {
336                    n1 * t * t
337                } else if t < 2.0 / d1 {
338                    t -= 1.5 / d1;
339                    n1 * t * t + 0.75
340                } else if t < 2.5 / d1 {
341                    t -= 2.25 / d1;
342                    n1 * t * t + 0.9375
343                } else {
344                    t -= 2.625 / d1;
345                    n1 * t * t + 0.984375
346                }
347            }
348        }
349    }
350}
351
352/// Animation configuration for a toast.
353#[derive(Debug, Clone)]
354pub struct ToastAnimationConfig {
355    /// Entrance animation type.
356    pub entrance: ToastEntranceAnimation,
357    /// Exit animation type.
358    pub exit: ToastExitAnimation,
359    /// Duration of entrance animation.
360    pub entrance_duration: Duration,
361    /// Duration of exit animation.
362    pub exit_duration: Duration,
363    /// Easing function for entrance.
364    pub entrance_easing: ToastEasing,
365    /// Easing function for exit.
366    pub exit_easing: ToastEasing,
367    /// Whether to respect reduced-motion preference.
368    pub respect_reduced_motion: bool,
369}
370
371impl Default for ToastAnimationConfig {
372    fn default() -> Self {
373        Self {
374            entrance: ToastEntranceAnimation::default(),
375            exit: ToastExitAnimation::default(),
376            entrance_duration: Duration::from_millis(200),
377            exit_duration: Duration::from_millis(150),
378            entrance_easing: ToastEasing::EaseOut,
379            exit_easing: ToastEasing::EaseIn,
380            respect_reduced_motion: true,
381        }
382    }
383}
384
385impl ToastAnimationConfig {
386    /// Create a config with no animations.
387    pub fn none() -> Self {
388        Self {
389            entrance: ToastEntranceAnimation::None,
390            exit: ToastExitAnimation::None,
391            entrance_duration: Duration::ZERO,
392            exit_duration: Duration::ZERO,
393            ..Default::default()
394        }
395    }
396
397    /// Check if animations are effectively disabled.
398    pub fn is_disabled(&self) -> bool {
399        matches!(self.entrance, ToastEntranceAnimation::None)
400            && matches!(self.exit, ToastExitAnimation::None)
401    }
402}
403
404/// Tracks the animation state for a toast.
405#[derive(Debug, Clone)]
406pub struct ToastAnimationState {
407    /// Current animation phase.
408    pub phase: ToastAnimationPhase,
409    /// When the current phase started.
410    pub phase_started: Instant,
411    /// Whether reduced motion is active.
412    pub reduced_motion: bool,
413}
414
415impl Default for ToastAnimationState {
416    fn default() -> Self {
417        Self {
418            phase: ToastAnimationPhase::Entering,
419            phase_started: Instant::now(),
420            reduced_motion: false,
421        }
422    }
423}
424
425impl ToastAnimationState {
426    /// Create a new animation state starting in the Entering phase.
427    pub fn new() -> Self {
428        Self::default()
429    }
430
431    /// Create a state with reduced motion enabled (skips to Visible).
432    pub fn with_reduced_motion() -> Self {
433        Self {
434            phase: ToastAnimationPhase::Visible,
435            phase_started: Instant::now(),
436            reduced_motion: true,
437        }
438    }
439
440    /// Get the progress within the current phase (0.0 to 1.0).
441    pub fn progress(&self, phase_duration: Duration) -> f64 {
442        if phase_duration.is_zero() {
443            return 1.0;
444        }
445        let elapsed = self.phase_started.elapsed();
446        (elapsed.as_secs_f64() / phase_duration.as_secs_f64()).min(1.0)
447    }
448
449    /// Transition to the next phase.
450    pub fn transition_to(&mut self, phase: ToastAnimationPhase) {
451        self.phase = phase;
452        self.phase_started = Instant::now();
453    }
454
455    /// Start the exit animation.
456    pub fn start_exit(&mut self) {
457        if self.reduced_motion {
458            self.transition_to(ToastAnimationPhase::Hidden);
459        } else {
460            self.transition_to(ToastAnimationPhase::Exiting);
461        }
462    }
463
464    /// Check if the animation has completed (Hidden phase).
465    pub fn is_complete(&self) -> bool {
466        self.phase == ToastAnimationPhase::Hidden
467    }
468
469    /// Update the animation state based on elapsed time.
470    ///
471    /// Returns true if the phase changed.
472    pub fn tick(&mut self, config: &ToastAnimationConfig) -> bool {
473        let prev_phase = self.phase;
474
475        match self.phase {
476            ToastAnimationPhase::Entering => {
477                let duration = if self.reduced_motion {
478                    Duration::ZERO
479                } else {
480                    config.entrance_duration
481                };
482                if self.progress(duration) >= 1.0 {
483                    self.transition_to(ToastAnimationPhase::Visible);
484                }
485            }
486            ToastAnimationPhase::Exiting => {
487                let duration = if self.reduced_motion {
488                    Duration::ZERO
489                } else {
490                    config.exit_duration
491                };
492                if self.progress(duration) >= 1.0 {
493                    self.transition_to(ToastAnimationPhase::Hidden);
494                }
495            }
496            ToastAnimationPhase::Visible | ToastAnimationPhase::Hidden => {}
497        }
498
499        self.phase != prev_phase
500    }
501
502    /// Calculate the current animation offset.
503    ///
504    /// Returns (dx, dy) offset to apply to the toast position.
505    pub fn current_offset(
506        &self,
507        config: &ToastAnimationConfig,
508        toast_width: u16,
509        toast_height: u16,
510    ) -> (i16, i16) {
511        if self.reduced_motion {
512            return (0, 0);
513        }
514
515        match self.phase {
516            ToastAnimationPhase::Entering => {
517                let raw_progress = self.progress(config.entrance_duration);
518                let eased_progress = config.entrance_easing.apply(raw_progress);
519                config
520                    .entrance
521                    .offset_at_progress(eased_progress, toast_width, toast_height)
522            }
523            ToastAnimationPhase::Exiting => {
524                let raw_progress = self.progress(config.exit_duration);
525                let eased_progress = config.exit_easing.apply(raw_progress);
526                config.exit.offset_at_progress(
527                    eased_progress,
528                    toast_width,
529                    toast_height,
530                    config.entrance,
531                )
532            }
533            ToastAnimationPhase::Visible => (0, 0),
534            ToastAnimationPhase::Hidden => (0, 0),
535        }
536    }
537
538    /// Calculate the current opacity (0.0 to 1.0).
539    ///
540    /// Used for fade animations.
541    pub fn current_opacity(&self, config: &ToastAnimationConfig) -> f64 {
542        if self.reduced_motion {
543            return if self.phase == ToastAnimationPhase::Hidden {
544                0.0
545            } else {
546                1.0
547            };
548        }
549
550        match self.phase {
551            ToastAnimationPhase::Entering => {
552                if matches!(config.entrance, ToastEntranceAnimation::FadeIn) {
553                    let raw_progress = self.progress(config.entrance_duration);
554                    config.entrance_easing.apply(raw_progress)
555                } else {
556                    1.0
557                }
558            }
559            ToastAnimationPhase::Exiting => {
560                if matches!(config.exit, ToastExitAnimation::FadeOut) {
561                    let raw_progress = self.progress(config.exit_duration);
562                    1.0 - config.exit_easing.apply(raw_progress)
563                } else {
564                    1.0
565                }
566            }
567            ToastAnimationPhase::Visible => 1.0,
568            ToastAnimationPhase::Hidden => 0.0,
569        }
570    }
571}
572
573/// Configuration for a toast notification.
574#[derive(Debug, Clone)]
575pub struct ToastConfig {
576    /// Position on screen.
577    pub position: ToastPosition,
578    /// Auto-dismiss duration. `None` means persistent until dismissed.
579    pub duration: Option<Duration>,
580    /// Whether the duration/persistence policy was explicitly configured by
581    /// the caller instead of inherited from a queue-level default.
582    pub duration_explicit: bool,
583    /// Visual style variant.
584    pub style_variant: ToastStyle,
585    /// Maximum width in columns.
586    pub max_width: u16,
587    /// Margin from screen edges.
588    pub margin: u16,
589    /// Whether the toast can be dismissed by the user.
590    pub dismissable: bool,
591    /// Animation configuration.
592    pub animation: ToastAnimationConfig,
593}
594
595impl Default for ToastConfig {
596    fn default() -> Self {
597        Self {
598            position: ToastPosition::default(),
599            duration: Some(Duration::from_secs(5)),
600            duration_explicit: false,
601            style_variant: ToastStyle::default(),
602            max_width: 50,
603            margin: 1,
604            dismissable: true,
605            animation: ToastAnimationConfig::default(),
606        }
607    }
608}
609
610/// Simplified key event for toast interaction handling.
611///
612/// This is a widget-level abstraction over terminal key events. The hosting
613/// application maps its native key events to these variants before passing
614/// them to `Toast::handle_key`.
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub enum KeyEvent {
617    /// Escape key — dismiss the toast.
618    Esc,
619    /// Tab key — cycle focus through action buttons.
620    Tab,
621    /// Enter key — invoke the focused action.
622    Enter,
623    /// Any other key (not consumed by the toast).
624    Other,
625}
626
627/// An interactive action button displayed in a toast.
628///
629/// Actions allow users to respond to a toast (e.g., "Undo", "Retry", "View").
630/// Each action has a display label and a unique identifier used to match
631/// callbacks when the action is invoked.
632///
633/// # Invariants
634///
635/// - `label` must be non-empty after trimming whitespace.
636/// - `id` must be non-empty; it serves as the stable callback key.
637/// - Display width of `label` is bounded by toast `max_width` minus chrome.
638///
639/// # Evidence Ledger
640///
641/// Action focus uses a simple round-robin model: Tab advances focus index
642/// modulo action count. This is deterministic and requires no scoring heuristic.
643/// The decision rule is: `next_focus = (current_focus + 1) % actions.len()`.
644#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct ToastAction {
646    /// Display label for the action button (e.g., "Undo").
647    pub label: String,
648    /// Unique identifier for callback matching.
649    pub id: String,
650}
651
652impl ToastAction {
653    /// Create a new toast action.
654    ///
655    /// # Panics
656    ///
657    /// Panics in debug builds if `label` or `id` is empty after trimming.
658    pub fn new(label: impl Into<String>, id: impl Into<String>) -> Self {
659        let label = label.into();
660        let id = id.into();
661        debug_assert!(
662            !label.trim().is_empty(),
663            "ToastAction label must not be empty"
664        );
665        debug_assert!(!id.trim().is_empty(), "ToastAction id must not be empty");
666        Self { label, id }
667    }
668
669    /// Display width of the action button including brackets.
670    ///
671    /// Rendered as `[Label]`, so width = label_width + 2 (brackets).
672    pub fn display_width(&self) -> usize {
673        display_width(self.label.as_str()) + 2 // [ + label + ]
674    }
675}
676
677/// Result of handling a toast interaction event.
678///
679/// Returned by `Toast::handle_key` to indicate what happened.
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub enum ToastEvent {
682    /// No interaction occurred (key not consumed).
683    None,
684    /// The toast was dismissed.
685    Dismissed,
686    /// An action button was invoked. Contains the action ID.
687    Action(String),
688    /// Focus moved between action buttons.
689    FocusChanged,
690}
691
692/// Content of a toast notification.
693#[derive(Debug, Clone)]
694pub struct ToastContent {
695    /// Main message text.
696    pub message: String,
697    /// Optional icon.
698    pub icon: Option<ToastIcon>,
699    /// Optional title.
700    pub title: Option<String>,
701}
702
703impl ToastContent {
704    /// Create new content with just a message.
705    pub fn new(message: impl Into<String>) -> Self {
706        Self {
707            message: message.into(),
708            icon: None,
709            title: None,
710        }
711    }
712
713    /// Set the icon.
714    #[must_use]
715    pub fn with_icon(mut self, icon: ToastIcon) -> Self {
716        self.icon = Some(icon);
717        self
718    }
719
720    /// Set the title.
721    #[must_use]
722    pub fn with_title(mut self, title: impl Into<String>) -> Self {
723        self.title = Some(title.into());
724        self
725    }
726}
727
728/// Internal state tracking for a toast.
729#[derive(Debug, Clone)]
730pub struct ToastState {
731    /// When the toast was created.
732    pub created_at: Instant,
733    /// Whether the toast has been dismissed.
734    pub dismissed: bool,
735    /// Animation state.
736    pub animation: ToastAnimationState,
737    /// Index of the currently focused action, if any.
738    pub focused_action: Option<usize>,
739    /// Whether the auto-dismiss timer is paused (e.g., due to action focus).
740    pub timer_paused: bool,
741    /// When the timer was paused, for calculating credited time.
742    pub pause_started: Option<Instant>,
743    /// Total duration the timer has been paused (accumulated across multiple pauses).
744    pub total_paused: Duration,
745}
746
747impl Default for ToastState {
748    fn default() -> Self {
749        Self {
750            created_at: Instant::now(),
751            dismissed: false,
752            animation: ToastAnimationState::default(),
753            focused_action: None,
754            timer_paused: false,
755            pause_started: None,
756            total_paused: Duration::ZERO,
757        }
758    }
759}
760
761impl ToastState {
762    /// Create a new state with reduced motion enabled.
763    pub fn with_reduced_motion() -> Self {
764        Self {
765            created_at: Instant::now(),
766            dismissed: false,
767            animation: ToastAnimationState::with_reduced_motion(),
768            focused_action: None,
769            timer_paused: false,
770            pause_started: None,
771            total_paused: Duration::ZERO,
772        }
773    }
774}
775
776/// A toast notification widget.
777///
778/// Toasts display transient messages to the user, typically in a corner
779/// of the screen. They can auto-dismiss after a duration or be manually
780/// dismissed.
781///
782/// # Example
783///
784/// ```ignore
785/// let toast = Toast::new("Operation completed")
786///     .icon(ToastIcon::Success)
787///     .position(ToastPosition::TopRight)
788///     .duration(Duration::from_secs(3));
789///
790/// // Render the toast
791/// toast.render(area, frame);
792/// ```
793#[derive(Debug, Clone)]
794pub struct Toast {
795    /// Unique identifier.
796    pub id: ToastId,
797    /// Toast content.
798    pub content: ToastContent,
799    /// Configuration.
800    pub config: ToastConfig,
801    /// Internal state.
802    pub state: ToastState,
803    /// Interactive action buttons (e.g., "Undo", "Retry").
804    pub actions: Vec<ToastAction>,
805    /// Base style override.
806    style: Style,
807    /// Icon style override.
808    icon_style: Style,
809    /// Title style override.
810    title_style: Style,
811    /// Style for action buttons.
812    action_style: Style,
813    /// Style for the focused action button.
814    action_focus_style: Style,
815}
816
817impl Toast {
818    /// Create a new toast with the given message.
819    pub fn new(message: impl Into<String>) -> Self {
820        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
821        let id = ToastId::new(NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
822
823        Self {
824            id,
825            content: ToastContent::new(message),
826            config: ToastConfig::default(),
827            state: ToastState::default(),
828            actions: Vec::new(),
829            style: Style::default(),
830            icon_style: Style::default(),
831            title_style: Style::default(),
832            action_style: Style::default(),
833            action_focus_style: Style::default(),
834        }
835    }
836
837    /// Create a toast with a specific ID.
838    pub fn with_id(id: ToastId, message: impl Into<String>) -> Self {
839        Self {
840            id,
841            content: ToastContent::new(message),
842            config: ToastConfig::default(),
843            state: ToastState::default(),
844            actions: Vec::new(),
845            style: Style::default(),
846            icon_style: Style::default(),
847            title_style: Style::default(),
848            action_style: Style::default(),
849            action_focus_style: Style::default(),
850        }
851    }
852
853    // --- Builder methods ---
854
855    /// Set the toast icon.
856    #[must_use]
857    pub fn icon(mut self, icon: ToastIcon) -> Self {
858        self.content.icon = Some(icon);
859        self
860    }
861
862    /// Set the toast title.
863    #[must_use]
864    pub fn title(mut self, title: impl Into<String>) -> Self {
865        self.content.title = Some(title.into());
866        self
867    }
868
869    /// Set the toast position.
870    #[must_use]
871    pub fn position(mut self, position: ToastPosition) -> Self {
872        self.config.position = position;
873        self
874    }
875
876    /// Set the auto-dismiss duration.
877    #[must_use]
878    pub fn duration(mut self, duration: Duration) -> Self {
879        self.config.duration = Some(duration);
880        self.config.duration_explicit = true;
881        self
882    }
883
884    /// Make the toast persistent (no auto-dismiss).
885    #[must_use]
886    pub fn persistent(mut self) -> Self {
887        self.config.duration = None;
888        self.config.duration_explicit = true;
889        self
890    }
891
892    /// Set the style variant.
893    #[must_use]
894    pub fn style_variant(mut self, variant: ToastStyle) -> Self {
895        self.config.style_variant = variant;
896        self
897    }
898
899    /// Set the maximum width.
900    #[must_use]
901    pub fn max_width(mut self, width: u16) -> Self {
902        self.config.max_width = width;
903        self
904    }
905
906    /// Set the margin from screen edges.
907    #[must_use]
908    pub fn margin(mut self, margin: u16) -> Self {
909        self.config.margin = margin;
910        self
911    }
912
913    /// Set whether the toast is dismissable.
914    #[must_use]
915    pub fn dismissable(mut self, dismissable: bool) -> Self {
916        self.config.dismissable = dismissable;
917        self
918    }
919
920    /// Set the base style.
921    #[must_use]
922    pub fn style(mut self, style: Style) -> Self {
923        self.style = style;
924        self
925    }
926
927    /// Set the icon style.
928    #[must_use]
929    pub fn with_icon_style(mut self, style: Style) -> Self {
930        self.icon_style = style;
931        self
932    }
933
934    /// Set the title style.
935    #[must_use]
936    pub fn with_title_style(mut self, style: Style) -> Self {
937        self.title_style = style;
938        self
939    }
940
941    // --- Animation builder methods ---
942
943    /// Set the entrance animation.
944    #[must_use]
945    pub fn entrance_animation(mut self, animation: ToastEntranceAnimation) -> Self {
946        self.config.animation.entrance = animation;
947        self
948    }
949
950    /// Set the exit animation.
951    #[must_use]
952    pub fn exit_animation(mut self, animation: ToastExitAnimation) -> Self {
953        self.config.animation.exit = animation;
954        self
955    }
956
957    /// Set the entrance animation duration.
958    #[must_use]
959    pub fn entrance_duration(mut self, duration: Duration) -> Self {
960        self.config.animation.entrance_duration = duration;
961        self
962    }
963
964    /// Set the exit animation duration.
965    #[must_use]
966    pub fn exit_duration(mut self, duration: Duration) -> Self {
967        self.config.animation.exit_duration = duration;
968        self
969    }
970
971    /// Set the entrance easing function.
972    #[must_use]
973    pub fn entrance_easing(mut self, easing: ToastEasing) -> Self {
974        self.config.animation.entrance_easing = easing;
975        self
976    }
977
978    /// Set the exit easing function.
979    #[must_use]
980    pub fn exit_easing(mut self, easing: ToastEasing) -> Self {
981        self.config.animation.exit_easing = easing;
982        self
983    }
984
985    // --- Action builder methods ---
986
987    /// Add a single action button to the toast.
988    #[must_use]
989    pub fn action(mut self, action: ToastAction) -> Self {
990        self.actions.push(action);
991        self
992    }
993
994    /// Set all action buttons at once.
995    #[must_use]
996    pub fn actions(mut self, actions: Vec<ToastAction>) -> Self {
997        self.actions = actions;
998        self
999    }
1000
1001    /// Set the style for action buttons.
1002    #[must_use]
1003    pub fn with_action_style(mut self, style: Style) -> Self {
1004        self.action_style = style;
1005        self
1006    }
1007
1008    /// Set the style for the focused action button.
1009    #[must_use]
1010    pub fn with_action_focus_style(mut self, style: Style) -> Self {
1011        self.action_focus_style = style;
1012        self
1013    }
1014
1015    /// Disable all animations.
1016    #[must_use]
1017    pub fn no_animation(mut self) -> Self {
1018        self.config.animation = ToastAnimationConfig::none();
1019        self.state.animation = ToastAnimationState {
1020            phase: ToastAnimationPhase::Visible,
1021            phase_started: Instant::now(),
1022            reduced_motion: true,
1023        };
1024        self
1025    }
1026
1027    /// Enable reduced motion mode (skips animations).
1028    #[must_use]
1029    pub fn reduced_motion(mut self, enabled: bool) -> Self {
1030        self.config.animation.respect_reduced_motion = enabled;
1031        if enabled {
1032            self.state.animation = ToastAnimationState::with_reduced_motion();
1033        }
1034        self
1035    }
1036
1037    // --- State methods ---
1038
1039    /// Check if the toast has expired based on its duration.
1040    ///
1041    /// Accounts for time spent paused (when actions are focused).
1042    pub fn is_expired(&self) -> bool {
1043        if let Some(duration) = self.config.duration {
1044            let wall_elapsed = self.state.created_at.elapsed();
1045            let mut paused = self.state.total_paused;
1046            if self.state.timer_paused
1047                && let Some(pause_start) = self.state.pause_started
1048            {
1049                paused += pause_start.elapsed();
1050            }
1051            let effective_elapsed = wall_elapsed.saturating_sub(paused);
1052            effective_elapsed >= duration
1053        } else {
1054            false
1055        }
1056    }
1057
1058    /// Check if the toast should be visible.
1059    ///
1060    /// A toast is visible if it's not in the Hidden animation phase.
1061    #[inline]
1062    pub fn is_visible(&self) -> bool {
1063        self.state.animation.phase != ToastAnimationPhase::Hidden
1064    }
1065
1066    /// Check if the toast is currently animating.
1067    pub fn is_animating(&self) -> bool {
1068        matches!(
1069            self.state.animation.phase,
1070            ToastAnimationPhase::Entering | ToastAnimationPhase::Exiting
1071        )
1072    }
1073
1074    /// Dismiss the toast, starting exit animation.
1075    pub fn dismiss(&mut self) {
1076        if !self.state.dismissed {
1077            self.state.dismissed = true;
1078            self.state.animation.start_exit();
1079        }
1080    }
1081
1082    /// Dismiss immediately without animation.
1083    pub fn dismiss_immediately(&mut self) {
1084        self.state.dismissed = true;
1085        self.state
1086            .animation
1087            .transition_to(ToastAnimationPhase::Hidden);
1088    }
1089
1090    /// Update the animation state. Call this each frame.
1091    ///
1092    /// Returns true if the animation phase changed.
1093    pub fn tick_animation(&mut self) -> bool {
1094        self.state.animation.tick(&self.config.animation)
1095    }
1096
1097    /// Get the current animation phase.
1098    pub fn animation_phase(&self) -> ToastAnimationPhase {
1099        self.state.animation.phase
1100    }
1101
1102    /// Get the current animation offset for rendering.
1103    ///
1104    /// Returns (dx, dy) offset to apply to the position.
1105    pub fn animation_offset(&self) -> (i16, i16) {
1106        let (width, height) = self.calculate_dimensions();
1107        self.state
1108            .animation
1109            .current_offset(&self.config.animation, width, height)
1110    }
1111
1112    /// Get the current opacity for rendering (0.0 to 1.0).
1113    pub fn animation_opacity(&self) -> f64 {
1114        self.state.animation.current_opacity(&self.config.animation)
1115    }
1116
1117    /// Get the remaining time before auto-dismiss.
1118    ///
1119    /// Accounts for paused time.
1120    #[must_use = "use the remaining time (if any) for scheduling"]
1121    pub fn remaining_time(&self) -> Option<Duration> {
1122        self.config.duration.map(|d| {
1123            let wall_elapsed = self.state.created_at.elapsed();
1124            let mut paused = self.state.total_paused;
1125            if self.state.timer_paused
1126                && let Some(pause_start) = self.state.pause_started
1127            {
1128                paused += pause_start.elapsed();
1129            }
1130            let effective_elapsed = wall_elapsed.saturating_sub(paused);
1131            d.saturating_sub(effective_elapsed)
1132        })
1133    }
1134
1135    // --- Interaction methods ---
1136
1137    /// Handle a key event for toast interaction.
1138    ///
1139    /// Supported keys:
1140    /// - `Esc`: Dismiss the toast (if dismissable).
1141    /// - `Tab`: Cycle focus through action buttons (round-robin).
1142    /// - `Enter`: Invoke the focused action. Returns `ToastEvent::Action(id)`.
1143    pub fn handle_key(&mut self, key: KeyEvent) -> ToastEvent {
1144        if !self.is_visible() || self.state.dismissed {
1145            return ToastEvent::None;
1146        }
1147
1148        match key {
1149            KeyEvent::Esc => {
1150                if self.has_focus() {
1151                    self.clear_focus();
1152                    ToastEvent::None
1153                } else if self.config.dismissable {
1154                    self.dismiss();
1155                    ToastEvent::Dismissed
1156                } else {
1157                    ToastEvent::None
1158                }
1159            }
1160            KeyEvent::Tab => {
1161                if self.actions.is_empty() {
1162                    return ToastEvent::None;
1163                }
1164                let next = match self.state.focused_action {
1165                    None => 0,
1166                    Some(i) => (i + 1) % self.actions.len(),
1167                };
1168                self.state.focused_action = Some(next);
1169                self.pause_timer();
1170                ToastEvent::FocusChanged
1171            }
1172            KeyEvent::Enter => {
1173                if let Some(idx) = self.state.focused_action
1174                    && let Some(action) = self.actions.get(idx)
1175                {
1176                    let id = action.id.clone();
1177                    self.dismiss();
1178                    return ToastEvent::Action(id);
1179                }
1180                ToastEvent::None
1181            }
1182            _ => ToastEvent::None,
1183        }
1184    }
1185
1186    /// Pause the auto-dismiss timer.
1187    pub fn pause_timer(&mut self) {
1188        if !self.state.timer_paused {
1189            self.state.timer_paused = true;
1190            self.state.pause_started = Some(Instant::now());
1191        }
1192    }
1193
1194    /// Resume the auto-dismiss timer.
1195    pub fn resume_timer(&mut self) {
1196        if self.state.timer_paused {
1197            if let Some(pause_start) = self.state.pause_started.take() {
1198                self.state.total_paused += pause_start.elapsed();
1199            }
1200            self.state.timer_paused = false;
1201        }
1202    }
1203
1204    /// Clear action focus and resume the timer.
1205    pub fn clear_focus(&mut self) {
1206        self.state.focused_action = None;
1207        self.resume_timer();
1208    }
1209
1210    /// Check whether any action is currently focused.
1211    pub fn has_focus(&self) -> bool {
1212        self.state.focused_action.is_some()
1213    }
1214
1215    /// Get the currently focused action, if any.
1216    #[must_use = "use the focused action (if any)"]
1217    pub fn focused_action(&self) -> Option<&ToastAction> {
1218        self.state
1219            .focused_action
1220            .and_then(|idx| self.actions.get(idx))
1221    }
1222
1223    /// Calculate the toast dimensions based on content.
1224    pub fn calculate_dimensions(&self) -> (u16, u16) {
1225        let max_width = self.config.max_width as usize;
1226
1227        // Calculate content width
1228        let icon_width = self
1229            .content
1230            .icon
1231            .map(|icon| {
1232                let mut buf = [0u8; 4];
1233                let s = icon.as_char().encode_utf8(&mut buf);
1234                display_width(s) + 1
1235            })
1236            .unwrap_or(0); // icon + space
1237        let message_width = display_width(self.content.message.as_str());
1238        let title_width = self
1239            .content
1240            .title
1241            .as_ref()
1242            .map(|t| display_width(t.as_str()))
1243            .unwrap_or(0);
1244
1245        // Content width is max of title and message (plus icon)
1246        let mut content_width = (icon_width + message_width).max(title_width);
1247
1248        // Account for actions row width: [Label] [Label] with spaces between
1249        if !self.actions.is_empty() {
1250            let actions_width: usize = self
1251                .actions
1252                .iter()
1253                .map(|a| a.display_width())
1254                .sum::<usize>()
1255                + self.actions.len().saturating_sub(1); // spaces between buttons
1256            content_width = content_width.max(actions_width);
1257        }
1258
1259        // Add padding (1 char each side) and border (1 char each side)
1260        let total_width = content_width.saturating_add(4).min(max_width);
1261
1262        // Height: border (2) + optional title (1) + message (1) + optional actions (1)
1263        let has_title = self.content.title.is_some();
1264        let has_actions = !self.actions.is_empty();
1265        let height = 3 + u16::from(has_title) + u16::from(has_actions);
1266
1267        (total_width as u16, height)
1268    }
1269}
1270
1271impl Widget for Toast {
1272    fn render(&self, area: Rect, frame: &mut Frame) {
1273        #[cfg(feature = "tracing")]
1274        let _span = tracing::debug_span!(
1275            "widget_render",
1276            widget = "Toast",
1277            x = area.x,
1278            y = area.y,
1279            w = area.width,
1280            h = area.height
1281        )
1282        .entered();
1283
1284        if area.is_empty() {
1285            return;
1286        }
1287
1288        // Calculate actual render area (use provided area or calculate from content)
1289        let (content_width, content_height) = self.calculate_dimensions();
1290        let width = area.width.min(content_width);
1291        let height = area.height.min(content_height);
1292
1293        if width < 3 || height < 3 {
1294            return; // Too small to render
1295        }
1296
1297        let render_area = Rect::new(area.x, area.y, width, height);
1298
1299        if !self.is_visible() {
1300            clear_text_area(frame, render_area, Style::default());
1301            return;
1302        }
1303
1304        let deg = frame.buffer.degradation;
1305        if !deg.render_content() {
1306            return;
1307        }
1308
1309        let base_style = if deg.apply_styling() {
1310            self.style
1311        } else {
1312            Style::default()
1313        };
1314        clear_text_area(frame, render_area, base_style);
1315
1316        // Draw border
1317        let use_unicode = deg.use_unicode_borders();
1318        let (tl, tr, bl, br, h, v) = if use_unicode {
1319            (
1320                '\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{2500}', '\u{2502}',
1321            )
1322        } else {
1323            ('+', '+', '+', '+', '-', '|')
1324        };
1325
1326        // Top border
1327        let mut cell = Cell::from_char(tl);
1328        if deg.apply_styling() {
1329            crate::apply_style(&mut cell, self.style);
1330        }
1331        frame.buffer.set_fast(render_area.x, render_area.y, cell);
1332
1333        for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1334            let mut cell = Cell::from_char(h);
1335            if deg.apply_styling() {
1336                crate::apply_style(&mut cell, self.style);
1337            }
1338            frame.buffer.set_fast(x, render_area.y, cell);
1339        }
1340
1341        let mut cell_tr = Cell::from_char(tr);
1342        if deg.apply_styling() {
1343            crate::apply_style(&mut cell_tr, self.style);
1344        }
1345        frame.buffer.set_fast(
1346            render_area.right().saturating_sub(1),
1347            render_area.y,
1348            cell_tr,
1349        );
1350
1351        // Bottom border
1352        let bottom_y = render_area.bottom().saturating_sub(1);
1353        let mut cell_bl = Cell::from_char(bl);
1354        if deg.apply_styling() {
1355            crate::apply_style(&mut cell_bl, self.style);
1356        }
1357        frame.buffer.set_fast(render_area.x, bottom_y, cell_bl);
1358
1359        for x in (render_area.x + 1)..(render_area.right().saturating_sub(1)) {
1360            let mut cell = Cell::from_char(h);
1361            if deg.apply_styling() {
1362                crate::apply_style(&mut cell, self.style);
1363            }
1364            frame.buffer.set_fast(x, bottom_y, cell);
1365        }
1366
1367        let mut cell_br = Cell::from_char(br);
1368        if deg.apply_styling() {
1369            crate::apply_style(&mut cell_br, self.style);
1370        }
1371        frame
1372            .buffer
1373            .set_fast(render_area.right().saturating_sub(1), bottom_y, cell_br);
1374
1375        // Side borders
1376        for y in (render_area.y + 1)..bottom_y {
1377            let mut cell_l = Cell::from_char(v);
1378            if deg.apply_styling() {
1379                crate::apply_style(&mut cell_l, self.style);
1380            }
1381            frame.buffer.set_fast(render_area.x, y, cell_l);
1382
1383            let mut cell_r = Cell::from_char(v);
1384            if deg.apply_styling() {
1385                crate::apply_style(&mut cell_r, self.style);
1386            }
1387            frame
1388                .buffer
1389                .set_fast(render_area.right().saturating_sub(1), y, cell_r);
1390        }
1391
1392        // Draw content
1393        let content_x = render_area.x + 1; // After left border
1394        let content_width = width.saturating_sub(2); // Minus borders
1395        let mut content_y = render_area.y + 1;
1396
1397        // Draw title if present
1398        if let Some(ref title) = self.content.title {
1399            let title_style = if deg.apply_styling() {
1400                self.title_style.merge(&self.style)
1401            } else {
1402                Style::default()
1403            };
1404
1405            let title_style = if deg.apply_styling() {
1406                title_style
1407            } else {
1408                Style::default()
1409            };
1410            crate::draw_text_span(
1411                frame,
1412                content_x,
1413                content_y,
1414                title,
1415                title_style,
1416                content_x + content_width,
1417            );
1418            content_y += 1;
1419        }
1420
1421        // Draw icon and message
1422        let mut msg_x = content_x;
1423
1424        if let Some(icon) = self.content.icon {
1425            let icon_char = if use_unicode {
1426                icon.as_char()
1427            } else {
1428                icon.as_ascii()
1429            };
1430
1431            let icon_style = if deg.apply_styling() {
1432                self.icon_style.merge(&self.style)
1433            } else {
1434                Style::default()
1435            };
1436            let icon_str = icon_char.to_string();
1437            msg_x = crate::draw_text_span(
1438                frame,
1439                msg_x,
1440                content_y,
1441                &icon_str,
1442                icon_style,
1443                content_x + content_width,
1444            );
1445            msg_x = crate::draw_text_span(
1446                frame,
1447                msg_x,
1448                content_y,
1449                " ",
1450                Style::default(),
1451                content_x + content_width,
1452            );
1453        }
1454
1455        // Draw message
1456        let msg_style = if deg.apply_styling() {
1457            self.style
1458        } else {
1459            Style::default()
1460        };
1461        crate::draw_text_span(
1462            frame,
1463            msg_x,
1464            content_y,
1465            &self.content.message,
1466            msg_style,
1467            content_x + content_width,
1468        );
1469
1470        // Draw action buttons if present
1471        if !self.actions.is_empty() {
1472            content_y += 1;
1473            let mut btn_x = content_x;
1474
1475            for (idx, action) in self.actions.iter().enumerate() {
1476                let is_focused = self.state.focused_action == Some(idx);
1477                let btn_style = if is_focused && deg.apply_styling() {
1478                    self.action_focus_style.merge(&self.style)
1479                } else if deg.apply_styling() {
1480                    self.action_style.merge(&self.style)
1481                } else {
1482                    Style::default()
1483                };
1484
1485                let max_x = content_x + content_width;
1486                let label = format!("[{}]", action.label);
1487                btn_x = crate::draw_text_span(frame, btn_x, content_y, &label, btn_style, max_x);
1488
1489                // Space between buttons
1490                if idx + 1 < self.actions.len() {
1491                    btn_x = crate::draw_text_span(
1492                        frame,
1493                        btn_x,
1494                        content_y,
1495                        " ",
1496                        Style::default(),
1497                        max_x,
1498                    );
1499                }
1500            }
1501        }
1502    }
1503
1504    fn is_essential(&self) -> bool {
1505        // Toasts are informational, not essential
1506        false
1507    }
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512    use super::*;
1513    use ftui_render::budget::DegradationLevel;
1514    use ftui_render::grapheme_pool::GraphemePool;
1515
1516    fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
1517        frame
1518            .buffer
1519            .get(x, y)
1520            .copied()
1521            .expect("test cell should exist")
1522    }
1523
1524    fn line_text(frame: &Frame, y: u16, width: u16) -> String {
1525        (0..width)
1526            .map(|x| {
1527                frame
1528                    .buffer
1529                    .get(x, y)
1530                    .and_then(|cell| cell.content.as_char())
1531                    .unwrap_or(' ')
1532            })
1533            .collect()
1534    }
1535
1536    fn focused_action_id(toast: &Toast) -> &str {
1537        toast
1538            .focused_action()
1539            .expect("focused action should exist")
1540            .id
1541            .as_str()
1542    }
1543
1544    fn unwrap_remaining(remaining: Option<Duration>) -> Duration {
1545        remaining.expect("remaining duration should exist")
1546    }
1547
1548    #[test]
1549    fn test_toast_new() {
1550        let toast = Toast::new("Hello");
1551        assert_eq!(toast.content.message, "Hello");
1552        assert!(toast.content.icon.is_none());
1553        assert!(toast.content.title.is_none());
1554        assert!(!toast.config.duration_explicit);
1555        assert!(toast.is_visible());
1556    }
1557
1558    #[test]
1559    fn test_toast_builder() {
1560        let toast = Toast::new("Test message")
1561            .icon(ToastIcon::Success)
1562            .title("Success")
1563            .position(ToastPosition::BottomRight)
1564            .duration(Duration::from_secs(10))
1565            .max_width(60);
1566
1567        assert_eq!(toast.content.message, "Test message");
1568        assert_eq!(toast.content.icon, Some(ToastIcon::Success));
1569        assert_eq!(toast.content.title, Some("Success".to_string()));
1570        assert_eq!(toast.config.position, ToastPosition::BottomRight);
1571        assert_eq!(toast.config.duration, Some(Duration::from_secs(10)));
1572        assert!(toast.config.duration_explicit);
1573        assert_eq!(toast.config.max_width, 60);
1574    }
1575
1576    #[test]
1577    fn test_toast_persistent() {
1578        let toast = Toast::new("Persistent").persistent();
1579        assert!(toast.config.duration.is_none());
1580        assert!(toast.config.duration_explicit);
1581        assert!(!toast.is_expired());
1582    }
1583
1584    #[test]
1585    fn test_toast_dismiss() {
1586        let mut toast = Toast::new("Dismissable").no_animation();
1587        assert!(toast.is_visible());
1588        toast.dismiss();
1589        assert!(!toast.is_visible());
1590        assert!(toast.state.dismissed);
1591    }
1592
1593    #[test]
1594    fn test_toast_position_calculate() {
1595        let terminal_width = 80;
1596        let terminal_height = 24;
1597        let toast_width = 30;
1598        let toast_height = 3;
1599        let margin = 1;
1600
1601        // Top-left
1602        let (x, y) = ToastPosition::TopLeft.calculate_position(
1603            terminal_width,
1604            terminal_height,
1605            toast_width,
1606            toast_height,
1607            margin,
1608        );
1609        assert_eq!(x, 1);
1610        assert_eq!(y, 1);
1611
1612        // Top-right
1613        let (x, y) = ToastPosition::TopRight.calculate_position(
1614            terminal_width,
1615            terminal_height,
1616            toast_width,
1617            toast_height,
1618            margin,
1619        );
1620        assert_eq!(x, 80 - 30 - 1); // 49
1621        assert_eq!(y, 1);
1622
1623        // Bottom-right
1624        let (x, y) = ToastPosition::BottomRight.calculate_position(
1625            terminal_width,
1626            terminal_height,
1627            toast_width,
1628            toast_height,
1629            margin,
1630        );
1631        assert_eq!(x, 49);
1632        assert_eq!(y, 24 - 3 - 1); // 20
1633
1634        // Top-center
1635        let (x, y) = ToastPosition::TopCenter.calculate_position(
1636            terminal_width,
1637            terminal_height,
1638            toast_width,
1639            toast_height,
1640            margin,
1641        );
1642        assert_eq!(x, (80 - 30) / 2); // 25
1643        assert_eq!(y, 1);
1644    }
1645
1646    #[test]
1647    fn test_toast_icon_chars() {
1648        assert_eq!(ToastIcon::Success.as_char(), '\u{2713}');
1649        assert_eq!(ToastIcon::Error.as_char(), '\u{2717}');
1650        assert_eq!(ToastIcon::Warning.as_char(), '!');
1651        assert_eq!(ToastIcon::Info.as_char(), 'i');
1652        assert_eq!(ToastIcon::Custom('*').as_char(), '*');
1653
1654        // ASCII fallbacks
1655        assert_eq!(ToastIcon::Success.as_ascii(), '+');
1656        assert_eq!(ToastIcon::Error.as_ascii(), 'x');
1657    }
1658
1659    #[test]
1660    fn test_toast_dimensions() {
1661        let toast = Toast::new("Short");
1662        let (w, h) = toast.calculate_dimensions();
1663        // "Short" = 5 chars + 4 (padding+border) = 9
1664        assert_eq!(w, 9);
1665        assert_eq!(h, 3); // No title
1666
1667        let toast_with_title = Toast::new("Message").title("Title");
1668        let (_w, h) = toast_with_title.calculate_dimensions();
1669        assert_eq!(h, 4); // With title
1670    }
1671
1672    #[test]
1673    fn test_toast_dimensions_with_icon() {
1674        let toast = Toast::new("Message").icon(ToastIcon::Success);
1675        let (w, _h) = toast.calculate_dimensions();
1676        let mut buf = [0u8; 4];
1677        let icon = ToastIcon::Success.as_char().encode_utf8(&mut buf);
1678        let expected = display_width(icon) + 1 + display_width("Message") + 4;
1679        assert_eq!(w, expected as u16);
1680    }
1681
1682    #[test]
1683    fn test_toast_dimensions_max_width() {
1684        let toast = Toast::new("This is a very long message that exceeds max width").max_width(20);
1685        let (w, _h) = toast.calculate_dimensions();
1686        assert!(w <= 20);
1687    }
1688
1689    #[test]
1690    fn test_toast_render_basic() {
1691        let toast = Toast::new("Hello");
1692        let area = Rect::new(0, 0, 15, 5);
1693        let mut pool = GraphemePool::new();
1694        let mut frame = Frame::new(15, 5, &mut pool);
1695        toast.render(area, &mut frame);
1696
1697        // Check border corners
1698        assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('\u{250C}')); // ┌
1699        assert!(frame.buffer.get(1, 1).is_some()); // Content area exists
1700    }
1701
1702    #[test]
1703    fn test_toast_render_with_icon() {
1704        let toast = Toast::new("OK").icon(ToastIcon::Success);
1705        let area = Rect::new(0, 0, 10, 5);
1706        let mut pool = GraphemePool::new();
1707        let mut frame = Frame::new(10, 5, &mut pool);
1708        toast.render(area, &mut frame);
1709
1710        // Icon should be at position (1, 1) - inside border
1711        let icon_cell = cell_at(&frame, 1, 1);
1712        let ok = if let Some(ch) = icon_cell.content.as_char() {
1713            ch == '\u{2713}'
1714        } else if let Some(id) = icon_cell.content.grapheme_id() {
1715            frame.pool.get(id) == Some("\u{2713}")
1716        } else {
1717            false
1718        };
1719        assert!(ok, "expected toast icon cell to contain ✓");
1720    }
1721
1722    #[test]
1723    fn test_toast_render_with_title() {
1724        let toast = Toast::new("Body").title("Head");
1725        let area = Rect::new(0, 0, 15, 6);
1726        let mut pool = GraphemePool::new();
1727        let mut frame = Frame::new(15, 6, &mut pool);
1728        toast.render(area, &mut frame);
1729
1730        // Title at row 1, message at row 2
1731        let title_cell = cell_at(&frame, 1, 1);
1732        assert_eq!(title_cell.content.as_char(), Some('H'));
1733    }
1734
1735    #[test]
1736    fn test_toast_render_zero_area() {
1737        let toast = Toast::new("Test");
1738        let area = Rect::new(0, 0, 0, 0);
1739        let mut pool = GraphemePool::new();
1740        let mut frame = Frame::new(1, 1, &mut pool);
1741        toast.render(area, &mut frame); // Should not panic
1742    }
1743
1744    #[test]
1745    fn test_toast_render_small_area() {
1746        let toast = Toast::new("Test");
1747        let area = Rect::new(0, 0, 2, 2);
1748        let mut pool = GraphemePool::new();
1749        let mut frame = Frame::new(2, 2, &mut pool);
1750        toast.render(area, &mut frame); // Should not render (too small)
1751    }
1752
1753    #[test]
1754    fn test_toast_not_visible_when_dismissed_clears_previous_render_area() {
1755        let mut toast = Toast::new("Test").no_animation();
1756        let area = Rect::new(0, 0, 20, 5);
1757        let mut pool = GraphemePool::new();
1758        let mut frame = Frame::new(20, 5, &mut pool);
1759        let (toast_width, toast_height) = toast.calculate_dimensions();
1760
1761        toast.render(area, &mut frame);
1762        toast.dismiss();
1763
1764        toast.render(area, &mut frame);
1765
1766        for y in 0..toast_height.min(area.height) {
1767            for x in 0..toast_width.min(area.width) {
1768                assert_eq!(cell_at(&frame, x, y).content.as_char(), Some(' '));
1769            }
1770        }
1771    }
1772
1773    #[test]
1774    fn test_toast_is_not_essential() {
1775        let toast = Toast::new("Test");
1776        assert!(!toast.is_essential());
1777    }
1778
1779    #[test]
1780    fn test_toast_simple_borders_use_ascii() {
1781        let toast = Toast::new("Hello");
1782        let area = Rect::new(0, 0, 15, 5);
1783        let mut pool = GraphemePool::new();
1784        let mut frame = Frame::new(15, 5, &mut pool);
1785        frame.buffer.degradation = DegradationLevel::SimpleBorders;
1786        toast.render(area, &mut frame);
1787
1788        assert_eq!(cell_at(&frame, 0, 0).content.as_char(), Some('+'));
1789        assert_eq!(cell_at(&frame, 1, 0).content.as_char(), Some('-'));
1790        assert_eq!(cell_at(&frame, 0, 1).content.as_char(), Some('|'));
1791    }
1792
1793    #[test]
1794    fn test_toast_skeleton_is_noop() {
1795        let toast = Toast::new("Hello").style_variant(ToastStyle::Success);
1796        let area = Rect::new(0, 0, 15, 5);
1797        let mut pool = GraphemePool::new();
1798        let mut frame = Frame::new(15, 5, &mut pool);
1799        let mut expected_pool = GraphemePool::new();
1800        let expected = Frame::new(15, 5, &mut expected_pool);
1801        frame.buffer.degradation = DegradationLevel::Skeleton;
1802        toast.render(area, &mut frame);
1803
1804        for y in 0..5 {
1805            for x in 0..15 {
1806                assert_eq!(frame.buffer.get(x, y), expected.buffer.get(x, y));
1807            }
1808        }
1809    }
1810
1811    #[test]
1812    fn test_toast_render_shorter_message_clears_stale_suffix() {
1813        let area = Rect::new(0, 0, 20, 5);
1814        let mut pool = GraphemePool::new();
1815        let mut frame = Frame::new(20, 5, &mut pool);
1816
1817        Toast::new("Long message text")
1818            .max_width(18)
1819            .no_animation()
1820            .render(area, &mut frame);
1821        Toast::new("Hi")
1822            .max_width(18)
1823            .no_animation()
1824            .render(area, &mut frame);
1825
1826        assert_eq!(line_text(&frame, 1, 6), "│Hi  │");
1827    }
1828
1829    #[test]
1830    fn test_toast_no_styling_shorter_title_and_message_clear_stale_text() {
1831        let area = Rect::new(0, 0, 18, 6);
1832        let mut pool = GraphemePool::new();
1833        let mut frame = Frame::new(18, 6, &mut pool);
1834
1835        Toast::new("Long body")
1836            .title("LongTitle")
1837            .max_width(16)
1838            .no_animation()
1839            .render(area, &mut frame);
1840
1841        frame.buffer.degradation = DegradationLevel::NoStyling;
1842        Toast::new("Ok")
1843            .title("Hi")
1844            .max_width(16)
1845            .no_animation()
1846            .render(area, &mut frame);
1847
1848        assert_eq!(line_text(&frame, 1, 6), "|Hi  |");
1849        assert_eq!(line_text(&frame, 2, 6), "|Ok  |");
1850    }
1851
1852    #[test]
1853    fn test_toast_id_uniqueness() {
1854        let toast1 = Toast::new("A");
1855        let toast2 = Toast::new("B");
1856        assert_ne!(toast1.id, toast2.id);
1857    }
1858
1859    #[test]
1860    fn test_toast_style_variants() {
1861        let success = Toast::new("OK").style_variant(ToastStyle::Success);
1862        let error = Toast::new("Fail").style_variant(ToastStyle::Error);
1863        let warning = Toast::new("Warn").style_variant(ToastStyle::Warning);
1864        let info = Toast::new("Info").style_variant(ToastStyle::Info);
1865        let neutral = Toast::new("Neutral").style_variant(ToastStyle::Neutral);
1866
1867        assert_eq!(success.config.style_variant, ToastStyle::Success);
1868        assert_eq!(error.config.style_variant, ToastStyle::Error);
1869        assert_eq!(warning.config.style_variant, ToastStyle::Warning);
1870        assert_eq!(info.config.style_variant, ToastStyle::Info);
1871        assert_eq!(neutral.config.style_variant, ToastStyle::Neutral);
1872    }
1873
1874    #[test]
1875    fn test_toast_content_builder() {
1876        let content = ToastContent::new("Message")
1877            .with_icon(ToastIcon::Warning)
1878            .with_title("Alert");
1879
1880        assert_eq!(content.message, "Message");
1881        assert_eq!(content.icon, Some(ToastIcon::Warning));
1882        assert_eq!(content.title, Some("Alert".to_string()));
1883    }
1884
1885    // --- Animation Tests ---
1886
1887    #[test]
1888    fn test_animation_phase_default() {
1889        let toast = Toast::new("Test");
1890        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
1891    }
1892
1893    #[test]
1894    fn test_animation_phase_reduced_motion() {
1895        let toast = Toast::new("Test").reduced_motion(true);
1896        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1897        assert!(toast.state.animation.reduced_motion);
1898    }
1899
1900    #[test]
1901    fn test_animation_no_animation() {
1902        let toast = Toast::new("Test").no_animation();
1903        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
1904        assert!(toast.config.animation.is_disabled());
1905    }
1906
1907    #[test]
1908    fn test_entrance_animation_builder() {
1909        let toast = Toast::new("Test")
1910            .entrance_animation(ToastEntranceAnimation::SlideFromTop)
1911            .entrance_duration(Duration::from_millis(300))
1912            .entrance_easing(ToastEasing::Bounce);
1913
1914        assert_eq!(
1915            toast.config.animation.entrance,
1916            ToastEntranceAnimation::SlideFromTop
1917        );
1918        assert_eq!(
1919            toast.config.animation.entrance_duration,
1920            Duration::from_millis(300)
1921        );
1922        assert_eq!(toast.config.animation.entrance_easing, ToastEasing::Bounce);
1923    }
1924
1925    #[test]
1926    fn test_exit_animation_builder() {
1927        let toast = Toast::new("Test")
1928            .exit_animation(ToastExitAnimation::SlideOut)
1929            .exit_duration(Duration::from_millis(100))
1930            .exit_easing(ToastEasing::EaseInOut);
1931
1932        assert_eq!(toast.config.animation.exit, ToastExitAnimation::SlideOut);
1933        assert_eq!(
1934            toast.config.animation.exit_duration,
1935            Duration::from_millis(100)
1936        );
1937        assert_eq!(toast.config.animation.exit_easing, ToastEasing::EaseInOut);
1938    }
1939
1940    #[test]
1941    fn test_entrance_animation_offsets() {
1942        let width = 30u16;
1943        let height = 5u16;
1944
1945        // SlideFromTop: starts above, ends at (0, 0)
1946        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.initial_offset(width, height);
1947        assert_eq!(dx, 0);
1948        assert_eq!(dy, -(height as i16));
1949
1950        // At progress 0.0, should be at initial offset
1951        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(0.0, width, height);
1952        assert_eq!(dx, 0);
1953        assert_eq!(dy, -(height as i16));
1954
1955        // At progress 1.0, should be at (0, 0)
1956        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(1.0, width, height);
1957        assert_eq!(dx, 0);
1958        assert_eq!(dy, 0);
1959
1960        // SlideFromRight: starts to the right
1961        let (dx, dy) = ToastEntranceAnimation::SlideFromRight.initial_offset(width, height);
1962        assert_eq!(dx, width as i16);
1963        assert_eq!(dy, 0);
1964    }
1965
1966    #[test]
1967    fn test_exit_animation_offsets() {
1968        let width = 30u16;
1969        let height = 5u16;
1970        let entrance = ToastEntranceAnimation::SlideFromRight;
1971
1972        // SlideOut reverses entrance direction
1973        let (dx, dy) = ToastExitAnimation::SlideOut.final_offset(width, height, entrance);
1974        assert_eq!(dx, -(width as i16)); // Opposite of SlideFromRight
1975        assert_eq!(dy, 0);
1976
1977        // At progress 0.0, should be at (0, 0)
1978        let (dx, dy) =
1979            ToastExitAnimation::SlideOut.offset_at_progress(0.0, width, height, entrance);
1980        assert_eq!(dx, 0);
1981        assert_eq!(dy, 0);
1982
1983        // At progress 1.0, should be at final offset
1984        let (dx, dy) =
1985            ToastExitAnimation::SlideOut.offset_at_progress(1.0, width, height, entrance);
1986        assert_eq!(dx, -(width as i16));
1987        assert_eq!(dy, 0);
1988    }
1989
1990    #[test]
1991    fn test_easing_apply() {
1992        // Linear: t = t
1993        assert!((ToastEasing::Linear.apply(0.5) - 0.5).abs() < 0.001);
1994
1995        // EaseOut at 0.5 should be > 0.5 (decelerating)
1996        assert!(ToastEasing::EaseOut.apply(0.5) > 0.5);
1997
1998        // EaseIn at 0.5 should be < 0.5 (accelerating)
1999        assert!(ToastEasing::EaseIn.apply(0.5) < 0.5);
2000
2001        // All should be 0 at 0 and 1 at 1
2002        for easing in [
2003            ToastEasing::Linear,
2004            ToastEasing::EaseIn,
2005            ToastEasing::EaseOut,
2006            ToastEasing::EaseInOut,
2007            ToastEasing::Bounce,
2008        ] {
2009            assert!((easing.apply(0.0) - 0.0).abs() < 0.001, "{:?} at 0", easing);
2010            assert!((easing.apply(1.0) - 1.0).abs() < 0.001, "{:?} at 1", easing);
2011        }
2012    }
2013
2014    #[test]
2015    fn test_animation_state_progress() {
2016        let state = ToastAnimationState::new();
2017        // Just created, progress should be very small
2018        let progress = state.progress(Duration::from_millis(200));
2019        assert!(
2020            progress < 0.1,
2021            "Progress should be small immediately after creation"
2022        );
2023    }
2024
2025    #[test]
2026    fn test_animation_state_zero_duration() {
2027        let state = ToastAnimationState::new();
2028        // Zero duration should return 1.0 (complete)
2029        let progress = state.progress(Duration::ZERO);
2030        assert_eq!(progress, 1.0);
2031    }
2032
2033    #[test]
2034    fn test_dismiss_starts_exit_animation() {
2035        let mut toast = Toast::new("Test").no_animation();
2036        // First set to visible phase
2037        toast.state.animation.phase = ToastAnimationPhase::Visible;
2038        toast.state.animation.reduced_motion = false;
2039
2040        toast.dismiss();
2041
2042        assert!(toast.state.dismissed);
2043        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Exiting);
2044    }
2045
2046    #[test]
2047    fn test_dismiss_immediately() {
2048        let mut toast = Toast::new("Test");
2049        toast.dismiss_immediately();
2050
2051        assert!(toast.state.dismissed);
2052        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Hidden);
2053        assert!(!toast.is_visible());
2054    }
2055
2056    #[test]
2057    fn test_is_animating() {
2058        let toast = Toast::new("Test");
2059        assert!(toast.is_animating()); // Starts in Entering phase
2060
2061        let toast_visible = Toast::new("Test").no_animation();
2062        assert!(!toast_visible.is_animating()); // No animation = Visible phase
2063    }
2064
2065    #[test]
2066    fn test_animation_opacity_fade_in() {
2067        let config = ToastAnimationConfig {
2068            entrance: ToastEntranceAnimation::FadeIn,
2069            exit: ToastExitAnimation::FadeOut,
2070            entrance_duration: Duration::from_millis(200),
2071            exit_duration: Duration::from_millis(150),
2072            entrance_easing: ToastEasing::Linear,
2073            exit_easing: ToastEasing::Linear,
2074            respect_reduced_motion: false,
2075        };
2076
2077        // At progress 0, opacity should be 0
2078        let mut state = ToastAnimationState::new();
2079        let opacity = state.current_opacity(&config);
2080        assert!(opacity < 0.1, "Should be low opacity at start");
2081
2082        // At progress 1 (Visible phase), opacity should be 1
2083        state.phase = ToastAnimationPhase::Visible;
2084        let opacity = state.current_opacity(&config);
2085        assert!((opacity - 1.0).abs() < 0.001);
2086    }
2087
2088    #[test]
2089    fn test_animation_config_default() {
2090        let config = ToastAnimationConfig::default();
2091
2092        assert_eq!(config.entrance, ToastEntranceAnimation::SlideFromRight);
2093        assert_eq!(config.exit, ToastExitAnimation::FadeOut);
2094        assert_eq!(config.entrance_duration, Duration::from_millis(200));
2095        assert_eq!(config.exit_duration, Duration::from_millis(150));
2096        assert!(config.respect_reduced_motion);
2097    }
2098
2099    #[test]
2100    fn test_animation_affects_position() {
2101        assert!(ToastEntranceAnimation::SlideFromTop.affects_position());
2102        assert!(ToastEntranceAnimation::SlideFromRight.affects_position());
2103        assert!(!ToastEntranceAnimation::FadeIn.affects_position());
2104        assert!(!ToastEntranceAnimation::None.affects_position());
2105
2106        assert!(ToastExitAnimation::SlideOut.affects_position());
2107        assert!(ToastExitAnimation::SlideToLeft.affects_position());
2108        assert!(!ToastExitAnimation::FadeOut.affects_position());
2109        assert!(!ToastExitAnimation::None.affects_position());
2110    }
2111
2112    #[test]
2113    fn test_toast_animation_offset() {
2114        let toast = Toast::new("Test").entrance_animation(ToastEntranceAnimation::SlideFromRight);
2115        let (dx, dy) = toast.animation_offset();
2116        // Should have positive dx (sliding from right)
2117        assert!(dx > 0, "Should have positive x offset at start");
2118        assert_eq!(dy, 0);
2119    }
2120
2121    // ── Interactive Toast Action tests ─────────────────────────────────
2122
2123    #[test]
2124    fn action_builder_single() {
2125        let toast = Toast::new("msg").action(ToastAction::new("Retry", "retry"));
2126        assert_eq!(toast.actions.len(), 1);
2127        assert_eq!(toast.actions[0].label, "Retry");
2128        assert_eq!(toast.actions[0].id, "retry");
2129    }
2130
2131    #[test]
2132    fn action_builder_multiple() {
2133        let toast = Toast::new("msg")
2134            .action(ToastAction::new("Ack", "ack"))
2135            .action(ToastAction::new("Snooze", "snooze"));
2136        assert_eq!(toast.actions.len(), 2);
2137    }
2138
2139    #[test]
2140    fn action_builder_vec() {
2141        let actions = vec![
2142            ToastAction::new("A", "a"),
2143            ToastAction::new("B", "b"),
2144            ToastAction::new("C", "c"),
2145        ];
2146        let toast = Toast::new("msg").actions(actions);
2147        assert_eq!(toast.actions.len(), 3);
2148    }
2149
2150    #[test]
2151    fn action_display_width() {
2152        let a = ToastAction::new("OK", "ok");
2153        // [OK] = 4 chars
2154        assert_eq!(a.display_width(), 4);
2155    }
2156
2157    #[test]
2158    fn handle_key_esc_dismisses() {
2159        let mut toast = Toast::new("msg").no_animation();
2160        let result = toast.handle_key(KeyEvent::Esc);
2161        assert_eq!(result, ToastEvent::Dismissed);
2162    }
2163
2164    #[test]
2165    fn handle_key_esc_clears_focus_first() {
2166        let mut toast = Toast::new("msg")
2167            .action(ToastAction::new("A", "a"))
2168            .no_animation();
2169        // First tab to focus
2170        toast.handle_key(KeyEvent::Tab);
2171        assert!(toast.has_focus());
2172        // Esc clears focus rather than dismissing
2173        let result = toast.handle_key(KeyEvent::Esc);
2174        assert_eq!(result, ToastEvent::None);
2175        assert!(!toast.has_focus());
2176    }
2177
2178    #[test]
2179    fn handle_key_tab_cycles_focus() {
2180        let mut toast = Toast::new("msg")
2181            .action(ToastAction::new("A", "a"))
2182            .action(ToastAction::new("B", "b"))
2183            .no_animation();
2184
2185        let r1 = toast.handle_key(KeyEvent::Tab);
2186        assert_eq!(r1, ToastEvent::FocusChanged);
2187        assert_eq!(toast.state.focused_action, Some(0));
2188
2189        let r2 = toast.handle_key(KeyEvent::Tab);
2190        assert_eq!(r2, ToastEvent::FocusChanged);
2191        assert_eq!(toast.state.focused_action, Some(1));
2192
2193        // Wraps around
2194        let r3 = toast.handle_key(KeyEvent::Tab);
2195        assert_eq!(r3, ToastEvent::FocusChanged);
2196        assert_eq!(toast.state.focused_action, Some(0));
2197    }
2198
2199    #[test]
2200    fn handle_key_tab_no_actions_is_noop() {
2201        let mut toast = Toast::new("msg").no_animation();
2202        let result = toast.handle_key(KeyEvent::Tab);
2203        assert_eq!(result, ToastEvent::None);
2204    }
2205
2206    #[test]
2207    fn handle_key_enter_invokes_action() {
2208        let mut toast = Toast::new("msg")
2209            .action(ToastAction::new("Retry", "retry"))
2210            .no_animation();
2211        toast.handle_key(KeyEvent::Tab); // focus action 0
2212        let result = toast.handle_key(KeyEvent::Enter);
2213        assert_eq!(result, ToastEvent::Action("retry".into()));
2214    }
2215
2216    #[test]
2217    fn handle_key_enter_no_focus_is_noop() {
2218        let mut toast = Toast::new("msg")
2219            .action(ToastAction::new("A", "a"))
2220            .no_animation();
2221        let result = toast.handle_key(KeyEvent::Enter);
2222        assert_eq!(result, ToastEvent::None);
2223    }
2224
2225    #[test]
2226    fn handle_key_other_is_noop() {
2227        let mut toast = Toast::new("msg").no_animation();
2228        let result = toast.handle_key(KeyEvent::Other);
2229        assert_eq!(result, ToastEvent::None);
2230    }
2231
2232    #[test]
2233    fn handle_key_dismissed_toast_is_noop() {
2234        let mut toast = Toast::new("msg").no_animation();
2235        toast.state.dismissed = true;
2236        let result = toast.handle_key(KeyEvent::Esc);
2237        assert_eq!(result, ToastEvent::None);
2238    }
2239
2240    #[test]
2241    fn pause_timer_sets_flag() {
2242        let mut toast = Toast::new("msg").no_animation();
2243        toast.pause_timer();
2244        assert!(toast.state.timer_paused);
2245        assert!(toast.state.pause_started.is_some());
2246    }
2247
2248    #[test]
2249    fn resume_timer_accumulates_paused() {
2250        let mut toast = Toast::new("msg").no_animation();
2251        toast.pause_timer();
2252        std::thread::sleep(Duration::from_millis(10));
2253        toast.resume_timer();
2254        assert!(!toast.state.timer_paused);
2255        assert!(toast.state.total_paused >= Duration::from_millis(5));
2256    }
2257
2258    #[test]
2259    fn pause_resume_idempotent() {
2260        let mut toast = Toast::new("msg").no_animation();
2261        // Double pause should not panic
2262        toast.pause_timer();
2263        toast.pause_timer();
2264        assert!(toast.state.timer_paused);
2265        // Double resume should not panic
2266        toast.resume_timer();
2267        toast.resume_timer();
2268        assert!(!toast.state.timer_paused);
2269    }
2270
2271    #[test]
2272    fn clear_focus_resumes_timer() {
2273        let mut toast = Toast::new("msg")
2274            .action(ToastAction::new("A", "a"))
2275            .no_animation();
2276        toast.handle_key(KeyEvent::Tab);
2277        assert!(toast.state.timer_paused);
2278        toast.clear_focus();
2279        assert!(!toast.has_focus());
2280        assert!(!toast.state.timer_paused);
2281    }
2282
2283    #[test]
2284    fn focused_action_returns_correct() {
2285        let mut toast = Toast::new("msg")
2286            .action(ToastAction::new("X", "x"))
2287            .action(ToastAction::new("Y", "y"))
2288            .no_animation();
2289        assert!(toast.focused_action().is_none());
2290        toast.handle_key(KeyEvent::Tab);
2291        assert_eq!(focused_action_id(&toast), "x");
2292        toast.handle_key(KeyEvent::Tab);
2293        assert_eq!(focused_action_id(&toast), "y");
2294    }
2295
2296    #[test]
2297    fn is_expired_accounts_for_pause() {
2298        let mut toast = Toast::new("msg")
2299            .duration(Duration::from_millis(50))
2300            .no_animation();
2301        toast.pause_timer();
2302        // Sleep past the duration while paused
2303        std::thread::sleep(Duration::from_millis(60));
2304        assert!(
2305            !toast.is_expired(),
2306            "Should not expire while timer is paused"
2307        );
2308        toast.resume_timer();
2309        // Not expired yet because paused time is subtracted
2310        assert!(
2311            !toast.is_expired(),
2312            "Should not expire immediately after resume because paused time was subtracted"
2313        );
2314    }
2315
2316    #[test]
2317    fn dimensions_include_actions_row() {
2318        let toast = Toast::new("Hi")
2319            .action(ToastAction::new("OK", "ok"))
2320            .no_animation();
2321        let (_, h) = toast.calculate_dimensions();
2322        // Without actions: 3 (border + message + border)
2323        // With actions: 4 (border + message + actions + border)
2324        assert_eq!(h, 4);
2325    }
2326
2327    #[test]
2328    fn dimensions_with_title_and_actions() {
2329        let toast = Toast::new("Hi")
2330            .title("Title")
2331            .action(ToastAction::new("OK", "ok"))
2332            .no_animation();
2333        let (_, h) = toast.calculate_dimensions();
2334        // border + title + message + actions + border = 5
2335        assert_eq!(h, 5);
2336    }
2337
2338    #[test]
2339    fn dimensions_width_accounts_for_actions() {
2340        let toast = Toast::new("Hi")
2341            .action(ToastAction::new("LongButtonLabel", "lb"))
2342            .no_animation();
2343        let (w, _) = toast.calculate_dimensions();
2344        // [LongButtonLabel] = 18 chars, plus 4 for borders/padding = 22
2345        // "Hi" = 2 chars + 4 = 6, so actions width dominates
2346        assert!(w >= 20);
2347    }
2348
2349    #[test]
2350    fn render_with_actions_does_not_panic() {
2351        let toast = Toast::new("Test")
2352            .action(ToastAction::new("OK", "ok"))
2353            .action(ToastAction::new("Cancel", "cancel"))
2354            .no_animation();
2355
2356        let mut pool = GraphemePool::new();
2357        let mut frame = Frame::new(60, 20, &mut pool);
2358        let area = Rect::new(0, 0, 40, 10);
2359        toast.render(area, &mut frame);
2360    }
2361
2362    #[test]
2363    fn render_focused_action_does_not_panic() {
2364        let mut toast = Toast::new("Test")
2365            .action(ToastAction::new("OK", "ok"))
2366            .no_animation();
2367        toast.handle_key(KeyEvent::Tab); // focus first action
2368
2369        let mut pool = GraphemePool::new();
2370        let mut frame = Frame::new(60, 20, &mut pool);
2371        let area = Rect::new(0, 0, 40, 10);
2372        toast.render(area, &mut frame);
2373    }
2374
2375    #[test]
2376    fn render_actions_tiny_area_does_not_panic() {
2377        let toast = Toast::new("X")
2378            .action(ToastAction::new("A", "a"))
2379            .no_animation();
2380
2381        let mut pool = GraphemePool::new();
2382        let mut frame = Frame::new(5, 3, &mut pool);
2383        let area = Rect::new(0, 0, 5, 3);
2384        toast.render(area, &mut frame);
2385    }
2386
2387    #[test]
2388    fn toast_action_styles() {
2389        let style = Style::new().bold();
2390        let focus_style = Style::new().italic();
2391        let toast = Toast::new("msg")
2392            .action(ToastAction::new("A", "a"))
2393            .with_action_style(style)
2394            .with_action_focus_style(focus_style);
2395        assert_eq!(toast.action_style, style);
2396        assert_eq!(toast.action_focus_style, focus_style);
2397    }
2398
2399    #[test]
2400    fn persistent_toast_not_expired_with_actions() {
2401        let toast = Toast::new("msg")
2402            .persistent()
2403            .action(ToastAction::new("Dismiss", "dismiss"))
2404            .no_animation();
2405        std::thread::sleep(Duration::from_millis(10));
2406        assert!(!toast.is_expired());
2407    }
2408
2409    #[test]
2410    fn action_invoke_second_button() {
2411        let mut toast = Toast::new("msg")
2412            .action(ToastAction::new("A", "a"))
2413            .action(ToastAction::new("B", "b"))
2414            .no_animation();
2415        toast.handle_key(KeyEvent::Tab); // focus 0
2416        toast.handle_key(KeyEvent::Tab); // focus 1
2417        let result = toast.handle_key(KeyEvent::Enter);
2418        assert_eq!(result, ToastEvent::Action("b".into()));
2419    }
2420
2421    #[test]
2422    fn remaining_time_with_pause() {
2423        let toast = Toast::new("msg")
2424            .duration(Duration::from_secs(10))
2425            .no_animation();
2426        let remaining = toast.remaining_time();
2427        assert!(remaining.is_some());
2428        let r = unwrap_remaining(remaining);
2429        assert!(r > Duration::from_secs(9));
2430    }
2431
2432    // =========================================================================
2433    // Position edge cases (bd-9vqk6)
2434    // =========================================================================
2435
2436    #[test]
2437    fn position_bottom_left() {
2438        let (x, y) = ToastPosition::BottomLeft.calculate_position(80, 24, 20, 3, 1);
2439        assert_eq!(x, 1);
2440        assert_eq!(y, 24 - 3 - 1); // 20
2441    }
2442
2443    #[test]
2444    fn position_bottom_center() {
2445        let (x, y) = ToastPosition::BottomCenter.calculate_position(80, 24, 20, 3, 1);
2446        assert_eq!(x, (80 - 20) / 2); // 30
2447        assert_eq!(y, 24 - 3 - 1); // 20
2448    }
2449
2450    #[test]
2451    fn position_toast_wider_than_terminal_saturates() {
2452        // Toast is wider than the terminal — x should saturate to 0, not wrap
2453        let (x, y) = ToastPosition::TopRight.calculate_position(20, 10, 30, 3, 1);
2454        assert_eq!(x, 0); // 20 - 30 = saturates to 0, then - 1 = still 0
2455        assert_eq!(y, 1);
2456    }
2457
2458    #[test]
2459    fn position_zero_margin() {
2460        let (x, y) = ToastPosition::TopLeft.calculate_position(80, 24, 20, 3, 0);
2461        assert_eq!(x, 0);
2462        assert_eq!(y, 0);
2463
2464        let (x, y) = ToastPosition::BottomRight.calculate_position(80, 24, 20, 3, 0);
2465        assert_eq!(x, 60);
2466        assert_eq!(y, 21);
2467    }
2468
2469    #[test]
2470    fn position_toast_taller_than_terminal_saturates() {
2471        let (_, y) = ToastPosition::BottomLeft.calculate_position(80, 3, 20, 10, 1);
2472        assert_eq!(y, 0); // 3 - 10 saturates to 0, then - 1 = still 0
2473    }
2474
2475    // =========================================================================
2476    // ToastIcon edge cases (bd-9vqk6)
2477    // =========================================================================
2478
2479    #[test]
2480    fn icon_custom_non_ascii_falls_back_to_star() {
2481        let icon = ToastIcon::Custom('\u{1F525}'); // 🔥
2482        assert_eq!(icon.as_char(), '\u{1F525}');
2483        assert_eq!(icon.as_ascii(), '*');
2484    }
2485
2486    #[test]
2487    fn icon_custom_ascii_preserved() {
2488        let icon = ToastIcon::Custom('#');
2489        assert_eq!(icon.as_char(), '#');
2490        assert_eq!(icon.as_ascii(), '#');
2491    }
2492
2493    #[test]
2494    fn icon_warning_ascii_same() {
2495        assert_eq!(ToastIcon::Warning.as_ascii(), '!');
2496        assert_eq!(ToastIcon::Info.as_ascii(), 'i');
2497    }
2498
2499    // =========================================================================
2500    // Default trait coverage (bd-9vqk6)
2501    // =========================================================================
2502
2503    #[test]
2504    fn toast_position_default_is_top_right() {
2505        assert_eq!(ToastPosition::default(), ToastPosition::TopRight);
2506    }
2507
2508    #[test]
2509    fn toast_icon_default_is_info() {
2510        assert_eq!(ToastIcon::default(), ToastIcon::Info);
2511    }
2512
2513    #[test]
2514    fn toast_style_default_is_info() {
2515        assert_eq!(ToastStyle::default(), ToastStyle::Info);
2516    }
2517
2518    #[test]
2519    fn toast_animation_phase_default_is_visible() {
2520        assert_eq!(ToastAnimationPhase::default(), ToastAnimationPhase::Visible);
2521    }
2522
2523    #[test]
2524    fn toast_entrance_animation_default_is_slide_from_right() {
2525        assert_eq!(
2526            ToastEntranceAnimation::default(),
2527            ToastEntranceAnimation::SlideFromRight
2528        );
2529    }
2530
2531    #[test]
2532    fn toast_exit_animation_default_is_fade_out() {
2533        assert_eq!(ToastExitAnimation::default(), ToastExitAnimation::FadeOut);
2534    }
2535
2536    #[test]
2537    fn toast_easing_default_is_ease_out() {
2538        assert_eq!(ToastEasing::default(), ToastEasing::EaseOut);
2539    }
2540
2541    // =========================================================================
2542    // Entrance animation all variants (bd-9vqk6)
2543    // =========================================================================
2544
2545    #[test]
2546    fn entrance_slide_from_bottom_offset() {
2547        let (dx, dy) = ToastEntranceAnimation::SlideFromBottom.initial_offset(20, 5);
2548        assert_eq!(dx, 0);
2549        assert_eq!(dy, 5); // starts below
2550    }
2551
2552    #[test]
2553    fn entrance_slide_from_left_offset() {
2554        let (dx, dy) = ToastEntranceAnimation::SlideFromLeft.initial_offset(20, 5);
2555        assert_eq!(dx, -20);
2556        assert_eq!(dy, 0);
2557    }
2558
2559    #[test]
2560    fn entrance_fade_in_no_offset() {
2561        let (dx, dy) = ToastEntranceAnimation::FadeIn.initial_offset(20, 5);
2562        assert_eq!(dx, 0);
2563        assert_eq!(dy, 0);
2564    }
2565
2566    #[test]
2567    fn entrance_none_no_offset() {
2568        let (dx, dy) = ToastEntranceAnimation::None.initial_offset(20, 5);
2569        assert_eq!(dx, 0);
2570        assert_eq!(dy, 0);
2571    }
2572
2573    #[test]
2574    fn entrance_offset_progress_clamped() {
2575        // Below 0 should clamp
2576        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(-0.5, 20, 5);
2577        assert_eq!(dx, 0);
2578        assert_eq!(dy, -5); // Same as progress 0.0
2579
2580        // Above 1 should clamp
2581        let (dx, dy) = ToastEntranceAnimation::SlideFromTop.offset_at_progress(2.0, 20, 5);
2582        assert_eq!(dx, 0);
2583        assert_eq!(dy, 0); // Same as progress 1.0
2584    }
2585
2586    #[test]
2587    fn entrance_offset_at_half_progress() {
2588        let (dx, dy) = ToastEntranceAnimation::SlideFromRight.offset_at_progress(0.5, 20, 5);
2589        assert_eq!(dx, 10); // Half of width
2590        assert_eq!(dy, 0);
2591    }
2592
2593    // =========================================================================
2594    // Exit animation all variants (bd-9vqk6)
2595    // =========================================================================
2596
2597    #[test]
2598    fn exit_slide_to_top_offset() {
2599        let entrance = ToastEntranceAnimation::SlideFromRight;
2600        let (dx, dy) = ToastExitAnimation::SlideToTop.final_offset(20, 5, entrance);
2601        assert_eq!(dx, 0);
2602        assert_eq!(dy, -5);
2603    }
2604
2605    #[test]
2606    fn exit_slide_to_right_offset() {
2607        let entrance = ToastEntranceAnimation::SlideFromRight;
2608        let (dx, dy) = ToastExitAnimation::SlideToRight.final_offset(20, 5, entrance);
2609        assert_eq!(dx, 20);
2610        assert_eq!(dy, 0);
2611    }
2612
2613    #[test]
2614    fn exit_slide_to_bottom_offset() {
2615        let entrance = ToastEntranceAnimation::SlideFromRight;
2616        let (dx, dy) = ToastExitAnimation::SlideToBottom.final_offset(20, 5, entrance);
2617        assert_eq!(dx, 0);
2618        assert_eq!(dy, 5);
2619    }
2620
2621    #[test]
2622    fn exit_slide_to_left_offset() {
2623        let entrance = ToastEntranceAnimation::SlideFromRight;
2624        let (dx, dy) = ToastExitAnimation::SlideToLeft.final_offset(20, 5, entrance);
2625        assert_eq!(dx, -20);
2626        assert_eq!(dy, 0);
2627    }
2628
2629    #[test]
2630    fn exit_fade_out_no_offset() {
2631        let entrance = ToastEntranceAnimation::SlideFromRight;
2632        let (dx, dy) = ToastExitAnimation::FadeOut.final_offset(20, 5, entrance);
2633        assert_eq!(dx, 0);
2634        assert_eq!(dy, 0);
2635    }
2636
2637    #[test]
2638    fn exit_none_no_offset() {
2639        let entrance = ToastEntranceAnimation::SlideFromRight;
2640        let (dx, dy) = ToastExitAnimation::None.final_offset(20, 5, entrance);
2641        assert_eq!(dx, 0);
2642        assert_eq!(dy, 0);
2643    }
2644
2645    #[test]
2646    fn exit_offset_progress_clamped() {
2647        let entrance = ToastEntranceAnimation::SlideFromRight;
2648        let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(-1.0, 20, 5, entrance);
2649        assert_eq!((dx, dy), (0, 0)); // Clamped to 0.0
2650
2651        let (dx, dy) = ToastExitAnimation::SlideToTop.offset_at_progress(5.0, 20, 5, entrance);
2652        assert_eq!((dx, dy), (0, -5)); // Clamped to 1.0
2653    }
2654
2655    // =========================================================================
2656    // Easing function edge cases (bd-9vqk6)
2657    // =========================================================================
2658
2659    #[test]
2660    fn easing_clamped_below_zero() {
2661        for easing in [
2662            ToastEasing::Linear,
2663            ToastEasing::EaseIn,
2664            ToastEasing::EaseOut,
2665            ToastEasing::EaseInOut,
2666            ToastEasing::Bounce,
2667        ] {
2668            let result = easing.apply(-0.5);
2669            assert!(
2670                (result - 0.0).abs() < 0.001,
2671                "{easing:?} at -0.5 should clamp to 0"
2672            );
2673        }
2674    }
2675
2676    #[test]
2677    fn easing_clamped_above_one() {
2678        for easing in [
2679            ToastEasing::Linear,
2680            ToastEasing::EaseIn,
2681            ToastEasing::EaseOut,
2682            ToastEasing::EaseInOut,
2683            ToastEasing::Bounce,
2684        ] {
2685            let result = easing.apply(1.5);
2686            assert!(
2687                (result - 1.0).abs() < 0.001,
2688                "{easing:?} at 1.5 should clamp to 1"
2689            );
2690        }
2691    }
2692
2693    #[test]
2694    fn easing_ease_in_out_first_half() {
2695        let result = ToastEasing::EaseInOut.apply(0.25);
2696        assert!(
2697            result < 0.25,
2698            "EaseInOut at 0.25 should be < 0.25 (accelerating)"
2699        );
2700    }
2701
2702    #[test]
2703    fn easing_ease_in_out_second_half() {
2704        let result = ToastEasing::EaseInOut.apply(0.75);
2705        assert!(
2706            result > 0.75,
2707            "EaseInOut at 0.75 should be > 0.75 (decelerating)"
2708        );
2709    }
2710
2711    #[test]
2712    fn easing_bounce_monotonic_at_key_points() {
2713        let d1 = 2.75;
2714        // Sample all four branches of the bounce function
2715        let t1 = 0.2 / d1; // first branch
2716        let t2 = 1.5 / d1; // second branch
2717        let t3 = 2.3 / d1; // third branch
2718        let t4 = 2.7 / d1; // fourth branch
2719
2720        let v1 = ToastEasing::Bounce.apply(t1);
2721        let v2 = ToastEasing::Bounce.apply(t2);
2722        let v3 = ToastEasing::Bounce.apply(t3);
2723        let v4 = ToastEasing::Bounce.apply(t4);
2724
2725        assert!((0.0..=1.0).contains(&v1), "branch 1: {v1}");
2726        assert!((0.0..=1.0).contains(&v2), "branch 2: {v2}");
2727        assert!((0.0..=1.0).contains(&v3), "branch 3: {v3}");
2728        assert!((0.0..=1.0).contains(&v4), "branch 4: {v4}");
2729    }
2730
2731    // =========================================================================
2732    // Animation state transitions (bd-9vqk6)
2733    // =========================================================================
2734
2735    #[test]
2736    fn animation_state_tick_entering_to_visible() {
2737        let config = ToastAnimationConfig {
2738            entrance_duration: Duration::ZERO, // Instant transition
2739            ..ToastAnimationConfig::default()
2740        };
2741        let mut state = ToastAnimationState::new();
2742        assert_eq!(state.phase, ToastAnimationPhase::Entering);
2743
2744        let changed = state.tick(&config);
2745        assert!(changed, "Phase should change from Entering to Visible");
2746        assert_eq!(state.phase, ToastAnimationPhase::Visible);
2747    }
2748
2749    #[test]
2750    fn animation_state_tick_exiting_to_hidden() {
2751        let config = ToastAnimationConfig {
2752            exit_duration: Duration::ZERO,
2753            ..ToastAnimationConfig::default()
2754        };
2755        let mut state = ToastAnimationState::new();
2756        state.transition_to(ToastAnimationPhase::Exiting);
2757
2758        let changed = state.tick(&config);
2759        assert!(changed, "Phase should change from Exiting to Hidden");
2760        assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2761    }
2762
2763    #[test]
2764    fn animation_state_tick_visible_no_change() {
2765        let config = ToastAnimationConfig::default();
2766        let mut state = ToastAnimationState::new();
2767        state.transition_to(ToastAnimationPhase::Visible);
2768
2769        let changed = state.tick(&config);
2770        assert!(!changed, "Visible phase should not auto-transition");
2771        assert_eq!(state.phase, ToastAnimationPhase::Visible);
2772    }
2773
2774    #[test]
2775    fn animation_state_tick_hidden_no_change() {
2776        let config = ToastAnimationConfig::default();
2777        let mut state = ToastAnimationState::new();
2778        state.transition_to(ToastAnimationPhase::Hidden);
2779
2780        let changed = state.tick(&config);
2781        assert!(!changed);
2782        assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2783    }
2784
2785    #[test]
2786    fn animation_state_start_exit_reduced_motion_goes_to_hidden() {
2787        let mut state = ToastAnimationState::with_reduced_motion();
2788        assert_eq!(state.phase, ToastAnimationPhase::Visible);
2789        state.start_exit();
2790        assert_eq!(state.phase, ToastAnimationPhase::Hidden);
2791    }
2792
2793    #[test]
2794    fn animation_state_is_complete() {
2795        let mut state = ToastAnimationState::new();
2796        assert!(!state.is_complete());
2797        state.transition_to(ToastAnimationPhase::Hidden);
2798        assert!(state.is_complete());
2799    }
2800
2801    // =========================================================================
2802    // Animation offset and opacity in all phases (bd-9vqk6)
2803    // =========================================================================
2804
2805    #[test]
2806    fn animation_offset_visible_is_zero() {
2807        let config = ToastAnimationConfig::default();
2808        let mut state = ToastAnimationState::new();
2809        state.phase = ToastAnimationPhase::Visible;
2810        let (dx, dy) = state.current_offset(&config, 20, 5);
2811        assert_eq!((dx, dy), (0, 0));
2812    }
2813
2814    #[test]
2815    fn animation_offset_hidden_is_zero() {
2816        let config = ToastAnimationConfig::default();
2817        let mut state = ToastAnimationState::new();
2818        state.phase = ToastAnimationPhase::Hidden;
2819        let (dx, dy) = state.current_offset(&config, 20, 5);
2820        assert_eq!((dx, dy), (0, 0));
2821    }
2822
2823    #[test]
2824    fn animation_offset_reduced_motion_always_zero() {
2825        let config = ToastAnimationConfig::default();
2826        let state = ToastAnimationState::with_reduced_motion();
2827        let (dx, dy) = state.current_offset(&config, 20, 5);
2828        assert_eq!((dx, dy), (0, 0));
2829    }
2830
2831    #[test]
2832    fn animation_opacity_visible_is_one() {
2833        let config = ToastAnimationConfig::default();
2834        let mut state = ToastAnimationState::new();
2835        state.phase = ToastAnimationPhase::Visible;
2836        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2837    }
2838
2839    #[test]
2840    fn animation_opacity_hidden_is_zero() {
2841        let config = ToastAnimationConfig::default();
2842        let mut state = ToastAnimationState::new();
2843        state.phase = ToastAnimationPhase::Hidden;
2844        assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2845    }
2846
2847    #[test]
2848    fn animation_opacity_reduced_motion_visible_is_one() {
2849        let config = ToastAnimationConfig::default();
2850        let mut state = ToastAnimationState::with_reduced_motion();
2851        state.phase = ToastAnimationPhase::Visible;
2852        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2853    }
2854
2855    #[test]
2856    fn animation_opacity_reduced_motion_hidden_is_zero() {
2857        let config = ToastAnimationConfig::default();
2858        let mut state = ToastAnimationState::with_reduced_motion();
2859        state.phase = ToastAnimationPhase::Hidden;
2860        assert!((state.current_opacity(&config) - 0.0).abs() < 0.001);
2861    }
2862
2863    #[test]
2864    fn animation_opacity_exiting_non_fade_is_one() {
2865        let config = ToastAnimationConfig {
2866            exit: ToastExitAnimation::SlideOut,
2867            ..ToastAnimationConfig::default()
2868        };
2869        let mut state = ToastAnimationState::new();
2870        state.phase = ToastAnimationPhase::Exiting;
2871        // Non-FadeOut exit keeps opacity at 1.0
2872        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2873    }
2874
2875    #[test]
2876    fn animation_opacity_entering_non_fade_is_one() {
2877        let config = ToastAnimationConfig {
2878            entrance: ToastEntranceAnimation::SlideFromTop,
2879            ..ToastAnimationConfig::default()
2880        };
2881        let mut state = ToastAnimationState::new();
2882        state.phase = ToastAnimationPhase::Entering;
2883        // Non-FadeIn entrance keeps opacity at 1.0
2884        assert!((state.current_opacity(&config) - 1.0).abs() < 0.001);
2885    }
2886
2887    // =========================================================================
2888    // Toast API coverage (bd-9vqk6)
2889    // =========================================================================
2890
2891    #[test]
2892    fn toast_with_id() {
2893        let toast = Toast::with_id(ToastId::new(42), "Custom ID");
2894        assert_eq!(toast.id, ToastId::new(42));
2895        assert_eq!(toast.content.message, "Custom ID");
2896    }
2897
2898    #[test]
2899    fn toast_tick_animation_returns_true_on_phase_change() {
2900        let mut toast = Toast::new("Test").entrance_duration(Duration::ZERO);
2901        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Entering);
2902        let changed = toast.tick_animation();
2903        assert!(changed);
2904        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2905    }
2906
2907    #[test]
2908    fn toast_tick_animation_returns_false_when_stable() {
2909        let mut toast = Toast::new("Test").no_animation();
2910        assert_eq!(toast.state.animation.phase, ToastAnimationPhase::Visible);
2911        let changed = toast.tick_animation();
2912        assert!(!changed);
2913    }
2914
2915    #[test]
2916    fn toast_animation_phase_accessor() {
2917        let toast = Toast::new("Test").no_animation();
2918        assert_eq!(toast.animation_phase(), ToastAnimationPhase::Visible);
2919    }
2920
2921    #[test]
2922    fn toast_animation_opacity_accessor() {
2923        let toast = Toast::new("Test").no_animation();
2924        assert!((toast.animation_opacity() - 1.0).abs() < 0.001);
2925    }
2926
2927    #[test]
2928    fn toast_remaining_time_persistent_is_none() {
2929        let toast = Toast::new("msg").persistent().no_animation();
2930        assert!(toast.remaining_time().is_none());
2931    }
2932
2933    #[test]
2934    fn toast_dismiss_twice_idempotent() {
2935        let mut toast = Toast::new("msg").no_animation();
2936        toast.state.animation.reduced_motion = false;
2937        toast.dismiss();
2938        assert!(toast.state.dismissed);
2939        let phase_after_first = toast.state.animation.phase;
2940        toast.dismiss(); // Should not change phase again
2941        assert_eq!(toast.state.animation.phase, phase_after_first);
2942    }
2943
2944    #[test]
2945    fn toast_non_dismissable_esc_noop() {
2946        let mut toast = Toast::new("msg").dismissable(false).no_animation();
2947        let result = toast.handle_key(KeyEvent::Esc);
2948        assert_eq!(result, ToastEvent::None);
2949        assert!(toast.is_visible());
2950    }
2951
2952    #[test]
2953    fn toast_margin_builder() {
2954        let toast = Toast::new("msg").margin(5);
2955        assert_eq!(toast.config.margin, 5);
2956    }
2957
2958    #[test]
2959    fn toast_with_icon_style_builder() {
2960        let style = Style::new().italic();
2961        let toast = Toast::new("msg").with_icon_style(style);
2962        assert_eq!(toast.icon_style, style);
2963    }
2964
2965    #[test]
2966    fn toast_with_title_style_builder() {
2967        let style = Style::new().bold();
2968        let toast = Toast::new("msg").with_title_style(style);
2969        assert_eq!(toast.title_style, style);
2970    }
2971
2972    // =========================================================================
2973    // ToastConfig defaults (bd-9vqk6)
2974    // =========================================================================
2975
2976    #[test]
2977    fn toast_config_default_values() {
2978        let config = ToastConfig::default();
2979        assert_eq!(config.position, ToastPosition::TopRight);
2980        assert_eq!(config.duration, Some(Duration::from_secs(5)));
2981        assert!(!config.duration_explicit);
2982        assert_eq!(config.style_variant, ToastStyle::Info);
2983        assert_eq!(config.max_width, 50);
2984        assert_eq!(config.margin, 1);
2985        assert!(config.dismissable);
2986    }
2987
2988    // =========================================================================
2989    // ToastAnimationConfig (bd-9vqk6)
2990    // =========================================================================
2991
2992    #[test]
2993    fn animation_config_none_fields() {
2994        let config = ToastAnimationConfig::none();
2995        assert_eq!(config.entrance, ToastEntranceAnimation::None);
2996        assert_eq!(config.exit, ToastExitAnimation::None);
2997        assert_eq!(config.entrance_duration, Duration::ZERO);
2998        assert_eq!(config.exit_duration, Duration::ZERO);
2999        assert!(config.is_disabled());
3000    }
3001
3002    #[test]
3003    fn animation_config_is_disabled_false_for_default() {
3004        let config = ToastAnimationConfig::default();
3005        assert!(!config.is_disabled());
3006    }
3007
3008    // =========================================================================
3009    // ToastId and trait coverage (bd-9vqk6)
3010    // =========================================================================
3011
3012    #[test]
3013    fn toast_id_hash_consistent() {
3014        use std::collections::HashSet;
3015        let mut set = HashSet::new();
3016        set.insert(ToastId::new(1));
3017        set.insert(ToastId::new(2));
3018        set.insert(ToastId::new(1)); // Duplicate
3019        assert_eq!(set.len(), 2);
3020    }
3021
3022    #[test]
3023    fn toast_id_debug() {
3024        let id = ToastId::new(42);
3025        let dbg = format!("{:?}", id);
3026        assert!(dbg.contains("42"), "Debug: {dbg}");
3027    }
3028
3029    #[test]
3030    fn toast_event_debug_clone() {
3031        let event = ToastEvent::Action("test".into());
3032        let dbg = format!("{:?}", event);
3033        assert!(dbg.contains("Action"), "Debug: {dbg}");
3034        let cloned = event.clone();
3035        assert_eq!(cloned, ToastEvent::Action("test".into()));
3036    }
3037
3038    #[test]
3039    fn key_event_traits() {
3040        let key = KeyEvent::Tab;
3041        let copy = key; // Copy
3042        assert_eq!(key, copy);
3043        let dbg = format!("{:?}", key);
3044        assert!(dbg.contains("Tab"), "Debug: {dbg}");
3045    }
3046
3047    // =========================================================================
3048    // Tick with reduced motion in entering phase (bd-9vqk6)
3049    // =========================================================================
3050
3051    #[test]
3052    fn animation_tick_entering_reduced_motion_transitions_immediately() {
3053        let config = ToastAnimationConfig::default();
3054        let mut state = ToastAnimationState {
3055            phase: ToastAnimationPhase::Entering,
3056            phase_started: Instant::now(),
3057            reduced_motion: true,
3058        };
3059        // With reduced_motion, entering duration is treated as ZERO → immediate transition
3060        let changed = state.tick(&config);
3061        assert!(changed);
3062        assert_eq!(state.phase, ToastAnimationPhase::Visible);
3063    }
3064
3065    #[test]
3066    fn animation_tick_exiting_reduced_motion_transitions_immediately() {
3067        let config = ToastAnimationConfig::default();
3068        let mut state = ToastAnimationState {
3069            phase: ToastAnimationPhase::Exiting,
3070            phase_started: Instant::now(),
3071            reduced_motion: true,
3072        };
3073        let changed = state.tick(&config);
3074        assert!(changed);
3075        assert_eq!(state.phase, ToastAnimationPhase::Hidden);
3076    }
3077}