Skip to main content

ratatui_toolkit/
toast.rs

1use ratatui::layout::{Alignment, Rect};
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap};
5use ratatui::Frame;
6use std::time::{Duration, Instant};
7
8/// Toast notification level
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ToastLevel {
11    Success,
12    Error,
13    Info,
14    Warning,
15}
16
17impl ToastLevel {
18    /// Get the color for this toast level
19    pub fn color(&self) -> Color {
20        match self {
21            ToastLevel::Success => Color::Green,
22            ToastLevel::Error => Color::Red,
23            ToastLevel::Info => Color::Cyan,
24            ToastLevel::Warning => Color::Yellow,
25        }
26    }
27
28    /// Get the icon for this toast level
29    pub fn icon(&self) -> &'static str {
30        match self {
31            ToastLevel::Success => "✓",
32            ToastLevel::Error => "✗",
33            ToastLevel::Info => "ℹ",
34            ToastLevel::Warning => "⚠",
35        }
36    }
37}
38
39/// A single toast notification
40#[derive(Debug, Clone)]
41pub struct Toast {
42    pub message: String,
43    pub level: ToastLevel,
44    pub created_at: Instant,
45    pub duration: Duration,
46}
47
48impl Toast {
49    /// Create a new toast with default 3 second duration
50    pub fn new(message: impl Into<String>, level: ToastLevel) -> Self {
51        Self {
52            message: message.into(),
53            level,
54            created_at: Instant::now(),
55            duration: Duration::from_secs(3),
56        }
57    }
58
59    /// Create a toast with custom duration
60    pub fn with_duration(
61        message: impl Into<String>,
62        level: ToastLevel,
63        duration: Duration,
64    ) -> Self {
65        Self {
66            message: message.into(),
67            level,
68            created_at: Instant::now(),
69            duration,
70        }
71    }
72
73    /// Check if this toast has expired
74    pub fn is_expired(&self) -> bool {
75        self.created_at.elapsed() >= self.duration
76    }
77
78    /// Get the remaining lifetime as a percentage (0.0 to 1.0)
79    pub fn lifetime_percent(&self) -> f32 {
80        let elapsed = self.created_at.elapsed().as_secs_f32();
81        let total = self.duration.as_secs_f32();
82        (total - elapsed) / total
83    }
84}
85
86/// Manages multiple toast notifications
87#[derive(Debug, Default)]
88pub struct ToastManager {
89    toasts: Vec<Toast>,
90    max_toasts: usize,
91}
92
93impl ToastManager {
94    /// Create a new toast manager
95    pub fn new() -> Self {
96        Self {
97            toasts: Vec::new(),
98            max_toasts: 5, // Maximum number of toasts to show at once
99        }
100    }
101
102    /// Add a new toast
103    pub fn add(&mut self, toast: Toast) {
104        // Remove expired toasts before adding new one
105        self.remove_expired();
106
107        // Add the new toast
108        self.toasts.push(toast);
109
110        // Keep only the most recent toasts if we exceed max
111        if self.toasts.len() > self.max_toasts {
112            self.toasts.drain(0..self.toasts.len() - self.max_toasts);
113        }
114
115        tracing::debug!("Toast added, total toasts: {}", self.toasts.len());
116    }
117
118    /// Add a success toast
119    pub fn success(&mut self, message: impl Into<String>) {
120        self.add(Toast::new(message, ToastLevel::Success));
121    }
122
123    /// Add an error toast
124    pub fn error(&mut self, message: impl Into<String>) {
125        self.add(Toast::new(message, ToastLevel::Error));
126    }
127
128    /// Add an info toast
129    pub fn info(&mut self, message: impl Into<String>) {
130        self.add(Toast::new(message, ToastLevel::Info));
131    }
132
133    /// Add a warning toast
134    pub fn warning(&mut self, message: impl Into<String>) {
135        self.add(Toast::new(message, ToastLevel::Warning));
136    }
137
138    /// Remove expired toasts
139    pub fn remove_expired(&mut self) {
140        let before = self.toasts.len();
141        self.toasts.retain(|toast| !toast.is_expired());
142        let removed = before - self.toasts.len();
143        if removed > 0 {
144            tracing::debug!("Removed {} expired toasts", removed);
145        }
146    }
147
148    /// Get all active toasts
149    pub fn get_active(&self) -> &[Toast] {
150        &self.toasts
151    }
152
153    /// Check if there are any active toasts
154    pub fn has_toasts(&self) -> bool {
155        !self.toasts.is_empty()
156    }
157
158    /// Clear all toasts
159    pub fn clear(&mut self) {
160        self.toasts.clear();
161    }
162}
163
164/// Render toasts in the bottom-right corner of the screen
165pub fn render_toasts(frame: &mut Frame, toasts: &ToastManager) {
166    let active_toasts = toasts.get_active();
167    if active_toasts.is_empty() {
168        return;
169    }
170
171    let area = frame.area();
172
173    // Constants for toast sizing
174    const TOAST_WIDTH: u16 = 40;
175    const TOAST_HEIGHT: u16 = 3; // 1 line of text + borders
176    const TOAST_MARGIN: u16 = 2;
177    const TOAST_SPACING: u16 = 1;
178
179    // Calculate position for toasts (bottom-right corner)
180    let mut y_offset = area.height.saturating_sub(TOAST_MARGIN);
181
182    // Render toasts from bottom to top (newest at bottom)
183    for toast in active_toasts.iter().rev() {
184        // Calculate toast area
185        let toast_y = y_offset.saturating_sub(TOAST_HEIGHT);
186        let toast_x = area.width.saturating_sub(TOAST_WIDTH + TOAST_MARGIN);
187
188        let toast_area = Rect {
189            x: toast_x,
190            y: toast_y,
191            width: TOAST_WIDTH,
192            height: TOAST_HEIGHT,
193        };
194
195        // Don't render if toast would be off-screen
196        if toast_y == 0 || toast_x == 0 {
197            break;
198        }
199
200        // Clear the area behind the toast
201        frame.render_widget(Clear, toast_area);
202
203        // Create the toast widget
204        let color = toast.level.color();
205        let icon = toast.level.icon();
206
207        let text = Line::from(vec![
208            Span::raw("  "), // Extra left margin
209            Span::styled(
210                icon,
211                Style::default().fg(color).add_modifier(Modifier::BOLD),
212            ),
213            Span::raw("  "), // Extra space after icon
214            Span::raw(&toast.message),
215            Span::raw(" "), // Extra right margin
216        ]);
217
218        let block = Block::default()
219            .borders(Borders::ALL)
220            .border_type(BorderType::Rounded)
221            .border_style(Style::default().fg(color));
222
223        let paragraph = Paragraph::new(text)
224            .block(block)
225            .alignment(Alignment::Left)
226            .wrap(Wrap { trim: true });
227
228        frame.render_widget(paragraph, toast_area);
229
230        // Move up for the next toast
231        y_offset = toast_y.saturating_sub(TOAST_SPACING);
232    }
233}