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