Skip to main content

ratatui_interact/components/
toast.rs

1//! Toast notification widget
2//!
3//! A transient notification popup that displays a message for a configurable duration.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{Toast, ToastState, ToastStyle};
9//! use ratatui::layout::Rect;
10//!
11//! // Create state
12//! let mut state = ToastState::new();
13//!
14//! // Show a toast for 3 seconds
15//! state.show("File saved successfully!", 3000);
16//!
17//! // In your render function
18//! if let Some(message) = state.get_message() {
19//!     let toast = Toast::new(message).style(ToastStyle::Info);
20//!     // render toast...
21//! }
22//!
23//! // In your event loop, periodically clear expired toasts
24//! state.clear_if_expired();
25//! ```
26
27use ratatui::{
28    buffer::Buffer,
29    layout::{Alignment, Rect},
30    style::{Color, Style},
31    widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
32};
33
34/// Style variants for toast notifications
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum ToastStyle {
37    /// Default informational style (cyan border)
38    #[default]
39    Info,
40    /// Success style (green border)
41    Success,
42    /// Warning style (yellow border)
43    Warning,
44    /// Error style (red border)
45    Error,
46}
47
48impl ToastStyle {
49    /// Get the border color for this style
50    pub fn border_color(&self) -> Color {
51        match self {
52            ToastStyle::Info => Color::Cyan,
53            ToastStyle::Success => Color::Green,
54            ToastStyle::Warning => Color::Yellow,
55            ToastStyle::Error => Color::Red,
56        }
57    }
58
59    /// Auto-detect style from message content
60    pub fn from_message(message: &str) -> Self {
61        let lower = message.to_lowercase();
62        if lower.contains("error") || lower.contains("fail") {
63            ToastStyle::Error
64        } else if lower.contains("warning") || lower.contains("warn") {
65            ToastStyle::Warning
66        } else if lower.contains("success") || lower.contains("saved") || lower.contains("done") {
67            ToastStyle::Success
68        } else {
69            ToastStyle::Info
70        }
71    }
72}
73
74/// State for managing toast visibility and expiration
75#[derive(Debug, Clone, Default)]
76pub struct ToastState {
77    /// Current message to display (if any)
78    message: Option<String>,
79    /// Expiration time (epoch milliseconds)
80    expires_at: Option<i64>,
81}
82
83impl ToastState {
84    /// Create a new toast state
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Show a toast message for a specified duration (in milliseconds)
90    pub fn show(&mut self, message: impl Into<String>, duration_ms: i64) {
91        let now = Self::current_time_ms();
92        self.message = Some(message.into());
93        self.expires_at = Some(now + duration_ms);
94    }
95
96    /// Get the current message if the toast hasn't expired
97    pub fn get_message(&self) -> Option<&str> {
98        if let (Some(msg), Some(expires)) = (&self.message, self.expires_at) {
99            let now = Self::current_time_ms();
100            if now < expires {
101                return Some(msg.as_str());
102            }
103        }
104        None
105    }
106
107    /// Check if a toast is currently visible
108    pub fn is_visible(&self) -> bool {
109        self.get_message().is_some()
110    }
111
112    /// Clear the toast if it has expired
113    pub fn clear_if_expired(&mut self) {
114        if let Some(expires) = self.expires_at {
115            let now = Self::current_time_ms();
116            if now >= expires {
117                self.message = None;
118                self.expires_at = None;
119            }
120        }
121    }
122
123    /// Force clear the toast immediately
124    pub fn clear(&mut self) {
125        self.message = None;
126        self.expires_at = None;
127    }
128
129    /// Get current time in milliseconds since epoch
130    fn current_time_ms() -> i64 {
131        std::time::SystemTime::now()
132            .duration_since(std::time::UNIX_EPOCH)
133            .map(|d| d.as_millis() as i64)
134            .unwrap_or(0)
135    }
136}
137
138/// Toast notification widget
139///
140/// Renders a centered popup with the given message.
141#[derive(Debug, Clone)]
142pub struct Toast<'a> {
143    message: &'a str,
144    style: ToastStyle,
145    auto_style: bool,
146    max_width: u16,
147    max_height: u16,
148    top_offset: u16,
149}
150
151impl<'a> Toast<'a> {
152    /// Create a new toast with the given message
153    pub fn new(message: &'a str) -> Self {
154        Self {
155            message,
156            style: ToastStyle::Info,
157            auto_style: true,
158            max_width: 80,
159            max_height: 8,
160            top_offset: 3,
161        }
162    }
163
164    /// Set the toast style
165    ///
166    /// This disables auto-style detection.
167    pub fn style(mut self, style: ToastStyle) -> Self {
168        self.style = style;
169        self.auto_style = false;
170        self
171    }
172
173    /// Enable auto-style detection from message content
174    pub fn auto_style(mut self) -> Self {
175        self.auto_style = true;
176        self
177    }
178
179    /// Set the maximum width of the toast
180    pub fn max_width(mut self, width: u16) -> Self {
181        self.max_width = width;
182        self
183    }
184
185    /// Set the maximum height of the toast
186    pub fn max_height(mut self, height: u16) -> Self {
187        self.max_height = height;
188        self
189    }
190
191    /// Set the offset from the top of the area
192    pub fn top_offset(mut self, offset: u16) -> Self {
193        self.top_offset = offset;
194        self
195    }
196
197    /// Calculate the toast area centered within the given area
198    pub fn calculate_area(&self, area: Rect) -> Rect {
199        // Calculate toast dimensions
200        let max_content_width = (area.width as usize)
201            .saturating_sub(8)
202            .min(self.max_width as usize);
203        let content_width = self.message.len() + 4; // padding
204        let toast_width = content_width.min(max_content_width).max(20) as u16;
205
206        // Calculate height based on text wrapping
207        let inner_width = toast_width.saturating_sub(2) as usize; // account for borders
208        let lines_needed = (self.message.len() + inner_width - 1) / inner_width.max(1);
209        let toast_height = (lines_needed as u16 + 2).min(self.max_height); // +2 for borders
210
211        // Center horizontally and position from top
212        let x = area.x + (area.width.saturating_sub(toast_width)) / 2;
213        let y = area.y
214            + self
215                .top_offset
216                .min(area.height.saturating_sub(toast_height));
217
218        Rect::new(x, y, toast_width, toast_height)
219    }
220
221    /// Render the toast, clearing the area behind it
222    ///
223    /// This is the preferred method as it ensures the toast appears on top.
224    pub fn render_with_clear(self, area: Rect, buf: &mut Buffer) {
225        let toast_area = self.calculate_area(area);
226
227        // Clear the area behind the toast
228        Clear.render(toast_area, buf);
229
230        // Render the toast
231        self.render_in_area(toast_area, buf);
232    }
233
234    /// Render the toast in a specific pre-calculated area
235    fn render_in_area(self, area: Rect, buf: &mut Buffer) {
236        let border_color = if self.auto_style {
237            ToastStyle::from_message(self.message).border_color()
238        } else {
239            self.style.border_color()
240        };
241
242        let block = Block::default()
243            .borders(Borders::ALL)
244            .border_style(Style::default().fg(border_color))
245            .style(Style::default().bg(Color::Black));
246
247        let paragraph = Paragraph::new(self.message)
248            .block(block)
249            .wrap(Wrap { trim: true })
250            .alignment(Alignment::Center)
251            .style(Style::default().fg(Color::White));
252
253        paragraph.render(area, buf);
254    }
255}
256
257impl Widget for Toast<'_> {
258    fn render(self, area: Rect, buf: &mut Buffer) {
259        // When used as a Widget directly, render in the given area
260        // For proper centering, use render_with_clear instead
261        self.render_in_area(area, buf);
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_toast_state_new() {
271        let state = ToastState::new();
272        assert!(state.message.is_none());
273        assert!(state.expires_at.is_none());
274    }
275
276    #[test]
277    fn test_toast_state_lifecycle() {
278        let mut state = ToastState::new();
279
280        // Initially no message
281        assert!(state.get_message().is_none());
282        assert!(!state.is_visible());
283
284        // Show a toast with very long duration
285        state.show("Test message", 100_000);
286        assert!(state.is_visible());
287        assert_eq!(state.get_message(), Some("Test message"));
288
289        // Force clear
290        state.clear();
291        assert!(!state.is_visible());
292    }
293
294    #[test]
295    fn test_toast_show_replaces_existing() {
296        let mut state = ToastState::new();
297
298        state.show("First message", 100_000);
299        assert_eq!(state.get_message(), Some("First message"));
300
301        state.show("Second message", 100_000);
302        assert_eq!(state.get_message(), Some("Second message"));
303    }
304
305    #[test]
306    fn test_toast_clear_if_expired() {
307        let mut state = ToastState::new();
308
309        // Show toast that expires immediately (duration 0)
310        state.show("Quick message", 0);
311        std::thread::sleep(std::time::Duration::from_millis(10));
312
313        state.clear_if_expired();
314        assert!(!state.is_visible());
315    }
316
317    #[test]
318    fn test_toast_style_detection() {
319        assert_eq!(
320            ToastStyle::from_message("error occurred"),
321            ToastStyle::Error
322        );
323        assert_eq!(ToastStyle::from_message("File saved"), ToastStyle::Success);
324        assert_eq!(
325            ToastStyle::from_message("Warning: low disk"),
326            ToastStyle::Warning
327        );
328        assert_eq!(ToastStyle::from_message("Hello world"), ToastStyle::Info);
329    }
330
331    #[test]
332    fn test_toast_style_detection_case_insensitive() {
333        assert_eq!(
334            ToastStyle::from_message("ERROR OCCURRED"),
335            ToastStyle::Error
336        );
337        assert_eq!(ToastStyle::from_message("FILE SAVED"), ToastStyle::Success);
338        assert_eq!(ToastStyle::from_message("WARNING"), ToastStyle::Warning);
339    }
340
341    #[test]
342    fn test_toast_style_detection_variants() {
343        assert_eq!(
344            ToastStyle::from_message("failed to load"),
345            ToastStyle::Error
346        );
347        assert_eq!(
348            ToastStyle::from_message("done processing"),
349            ToastStyle::Success
350        );
351        assert_eq!(
352            ToastStyle::from_message("warn: deprecated"),
353            ToastStyle::Warning
354        );
355    }
356
357    #[test]
358    fn test_toast_style_colors() {
359        assert_eq!(ToastStyle::Info.border_color(), Color::Cyan);
360        assert_eq!(ToastStyle::Success.border_color(), Color::Green);
361        assert_eq!(ToastStyle::Warning.border_color(), Color::Yellow);
362        assert_eq!(ToastStyle::Error.border_color(), Color::Red);
363    }
364
365    #[test]
366    fn test_toast_style_default() {
367        let style = ToastStyle::default();
368        assert_eq!(style, ToastStyle::Info);
369    }
370
371    #[test]
372    fn test_toast_area_calculation() {
373        let toast = Toast::new("Hello");
374        let area = Rect::new(0, 0, 100, 50);
375        let toast_area = toast.calculate_area(area);
376
377        // Should be centered horizontally
378        assert!(toast_area.x > 0);
379        assert!(toast_area.x + toast_area.width <= area.width);
380
381        // Should be near top
382        assert_eq!(toast_area.y, 3); // default top_offset
383    }
384
385    #[test]
386    fn test_toast_area_calculation_long_message() {
387        let long_msg = "This is a very long message that should wrap to multiple lines";
388        let toast = Toast::new(long_msg);
389        let area = Rect::new(0, 0, 40, 20);
390        let toast_area = toast.calculate_area(area);
391
392        // Width should be constrained
393        assert!(toast_area.width <= area.width);
394    }
395
396    #[test]
397    fn test_toast_area_calculation_custom_offset() {
398        let toast = Toast::new("Hello").top_offset(10);
399        let area = Rect::new(0, 0, 100, 50);
400        let toast_area = toast.calculate_area(area);
401
402        assert_eq!(toast_area.y, 10);
403    }
404
405    #[test]
406    fn test_toast_builder_methods() {
407        let toast = Toast::new("Test")
408            .style(ToastStyle::Error)
409            .max_width(60)
410            .max_height(5)
411            .top_offset(5);
412
413        assert_eq!(toast.style, ToastStyle::Error);
414        assert_eq!(toast.max_width, 60);
415        assert_eq!(toast.max_height, 5);
416        assert_eq!(toast.top_offset, 5);
417        assert!(!toast.auto_style); // auto_style disabled when style is set
418    }
419
420    #[test]
421    fn test_toast_auto_style() {
422        let toast = Toast::new("Test").auto_style();
423        assert!(toast.auto_style);
424    }
425
426    #[test]
427    fn test_toast_render() {
428        let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
429        let toast = Toast::new("Test toast message");
430
431        toast.render_with_clear(Rect::new(0, 0, 60, 20), &mut buf);
432
433        // Check that something was rendered (borders at least)
434        // The toast should contain the message text
435        let content: String = buf.content.iter().map(|c| c.symbol()).collect();
436        assert!(content.contains("Test"));
437    }
438
439    #[test]
440    fn test_toast_render_with_style() {
441        let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
442        let toast = Toast::new("Success!").style(ToastStyle::Success);
443
444        toast.render_with_clear(Rect::new(0, 0, 60, 20), &mut buf);
445
446        let content: String = buf.content.iter().map(|c| c.symbol()).collect();
447        assert!(content.contains("Success"));
448    }
449
450    #[test]
451    fn test_toast_widget_render() {
452        let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
453        let toast = Toast::new("Widget test");
454        let area = Rect::new(0, 0, 30, 5);
455
456        toast.render(area, &mut buf);
457        // Should not panic
458    }
459}