Skip to main content

corty_tui/widgets/
toaster.rs

1//! Toaster widget for displaying temporary notifications
2//!
3//! This widget shows error messages and other notifications that appear
4//! temporarily and then fade away. Messages are stacked vertically and
5//! can be dismissed manually or automatically after a timeout.
6
7use super::constants::*;
8use ratatui::{
9    buffer::Buffer,
10    layout::{Alignment, Rect},
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, BorderType, Borders, Paragraph, Widget},
14};
15use std::time::{Duration, Instant};
16
17/// Maximum number of toast messages to display simultaneously
18const MAX_TOASTS: usize = 3;
19
20/// How long a toast message stays visible before auto-dismissing
21const TOAST_DURATION: Duration = Duration::from_secs(5);
22
23/// A single toast notification
24#[derive(Debug, Clone)]
25pub struct Toast {
26    /// The message to display
27    pub message: String,
28    /// The type of toast (affects styling)
29    pub kind: ToastKind,
30    /// When this toast was created
31    pub created_at: Instant,
32}
33
34/// Types of toast notifications
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ToastKind {
37    /// Error messages (red)
38    Error,
39    /// Warning messages (yellow)
40    #[allow(dead_code)]
41    Warning,
42    /// Info messages (blue)
43    #[allow(dead_code)]
44    Info,
45    /// Success messages (green)
46    #[allow(dead_code)]
47    Success,
48}
49
50impl ToastKind {
51    /// Get the color for this toast type
52    fn color(&self) -> Color {
53        match self {
54            ToastKind::Error => Color::Rgb(239, 68, 68),    // Red
55            ToastKind::Warning => Color::Rgb(245, 158, 11), // Yellow
56            ToastKind::Info => Color::Rgb(59, 130, 246),    // Blue
57            ToastKind::Success => Color::Rgb(34, 197, 94),  // Green
58        }
59    }
60
61    /// Get the icon for this toast type
62    fn icon(&self) -> &'static str {
63        match self {
64            ToastKind::Error => TOAST_ICON_ERROR,
65            ToastKind::Warning => TOAST_ICON_WARNING,
66            ToastKind::Info => TOAST_ICON_INFO,
67            ToastKind::Success => TOAST_ICON_SUCCESS,
68        }
69    }
70
71    /// Get the title for this toast type
72    fn title(&self) -> &'static str {
73        match self {
74            ToastKind::Error => TOAST_TITLE_ERROR,
75            ToastKind::Warning => TOAST_TITLE_WARNING,
76            ToastKind::Info => TOAST_TITLE_INFO,
77            ToastKind::Success => TOAST_TITLE_SUCCESS,
78        }
79    }
80}
81
82/// State for managing toast notifications
83pub struct ToasterState {
84    /// Active toast messages
85    toasts: Vec<Toast>,
86}
87
88impl Default for ToasterState {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl ToasterState {
95    /// Create a new toaster state
96    pub fn new() -> Self {
97        Self { toasts: Vec::new() }
98    }
99
100    /// Add a new toast message
101    pub fn add_toast(&mut self, message: String, kind: ToastKind) {
102        let toast = Toast {
103            message,
104            kind,
105            created_at: Instant::now(),
106        };
107
108        self.toasts.push(toast);
109
110        // Keep only the most recent MAX_TOASTS
111        if self.toasts.len() > MAX_TOASTS {
112            self.toasts.drain(0..self.toasts.len() - MAX_TOASTS);
113        }
114    }
115
116    /// Add an error toast
117    pub fn error(&mut self, message: impl Into<String>) {
118        self.add_toast(message.into(), ToastKind::Error);
119    }
120
121    /// Add a warning toast
122    #[allow(dead_code)]
123    pub fn warning(&mut self, message: impl Into<String>) {
124        self.add_toast(message.into(), ToastKind::Warning);
125    }
126
127    /// Add an info toast
128    #[allow(dead_code)]
129    pub fn info(&mut self, message: impl Into<String>) {
130        self.add_toast(message.into(), ToastKind::Info);
131    }
132
133    /// Add a success toast
134    #[allow(dead_code)]
135    pub fn success(&mut self, message: impl Into<String>) {
136        self.add_toast(message.into(), ToastKind::Success);
137    }
138
139    /// Remove expired toasts
140    pub fn tick(&mut self) {
141        let now = Instant::now();
142        self.toasts
143            .retain(|toast| now.duration_since(toast.created_at) < TOAST_DURATION);
144    }
145
146    /// Check if there are any active toasts
147    pub fn has_toasts(&self) -> bool {
148        !self.toasts.is_empty()
149    }
150
151    /// Get the active toasts
152    pub fn toasts(&self) -> &[Toast] {
153        &self.toasts
154    }
155
156    /// Clear all toasts
157    #[allow(dead_code)]
158    pub fn clear(&mut self) {
159        self.toasts.clear();
160    }
161}
162
163/// Widget for rendering toast notifications
164pub struct Toaster<'a> {
165    state: &'a ToasterState,
166}
167
168impl<'a> Toaster<'a> {
169    /// Create a new toaster widget
170    pub fn new(state: &'a ToasterState) -> Self {
171        Self { state }
172    }
173
174    /// Calculate the position for toasts (top-right corner with padding)
175    fn calculate_position(&self, container: Rect) -> (u16, u16) {
176        let toast_width = self.calculate_width();
177        let x = container.x + container.width.saturating_sub(toast_width + 2);
178        let y = container.y + 2;
179        (x, y)
180    }
181
182    /// Calculate the width needed for toasts
183    fn calculate_width(&self) -> u16 {
184        // Find the longest message
185        let max_message_len = self
186            .state
187            .toasts()
188            .iter()
189            .map(|t| t.message.len())
190            .max()
191            .unwrap_or(0);
192
193        // Add space for icon, title, borders, and padding
194        (max_message_len + 20).min(60) as u16
195    }
196
197    /// Calculate the height of a single toast
198    fn calculate_toast_height(&self, toast: &Toast, width: u16) -> u16 {
199        // Account for wrapping - very simple estimation
200        let content_width = width.saturating_sub(4) as usize; // borders and padding
201        let lines = toast.message.len().div_ceil(content_width);
202        // 1 line for title, N lines for message, 2 for borders
203        (lines as u16 + 3).min(6)
204    }
205}
206
207impl<'a> Widget for Toaster<'a> {
208    fn render(self, area: Rect, buf: &mut Buffer) {
209        if !self.state.has_toasts() {
210            return;
211        }
212
213        let (x, mut y) = self.calculate_position(area);
214        let width = self.calculate_width();
215
216        // Render each toast
217        for (idx, toast) in self.state.toasts().iter().enumerate() {
218            let height = self.calculate_toast_height(toast, width);
219
220            // Make sure we don't render outside the area
221            if y + height > area.y + area.height {
222                break;
223            }
224
225            let toast_area = Rect {
226                x,
227                y,
228                width,
229                height,
230            };
231
232            // Calculate opacity based on age and position
233            let age_factor = {
234                let elapsed = toast.created_at.elapsed();
235                if elapsed >= TOAST_DURATION.mul_f32(0.8) {
236                    // Start fading in the last 20% of duration
237                    let fade_progress = (elapsed.as_secs_f32()
238                        - TOAST_DURATION.as_secs_f32() * 0.8)
239                        / (TOAST_DURATION.as_secs_f32() * 0.2);
240                    1.0 - fade_progress.min(1.0)
241                } else {
242                    1.0
243                }
244            };
245
246            // Older toasts are more transparent
247            let position_factor = 1.0 - (idx as f32 * 0.2);
248            let opacity_factor = (age_factor * position_factor).max(0.3);
249
250            // Create the toast content
251            let color = toast.kind.color();
252            let dimmed_color = if opacity_factor < 0.8 {
253                Color::Rgb(
254                    (color.r() as f32 * opacity_factor) as u8,
255                    (color.g() as f32 * opacity_factor) as u8,
256                    (color.b() as f32 * opacity_factor) as u8,
257                )
258            } else {
259                color
260            };
261
262            // Create title line
263            let title_line = Line::from(vec![
264                Span::styled(
265                    format!("{} ", toast.kind.icon()),
266                    Style::default()
267                        .fg(dimmed_color)
268                        .add_modifier(Modifier::BOLD),
269                ),
270                Span::styled(
271                    toast.kind.title(),
272                    Style::default()
273                        .fg(dimmed_color)
274                        .add_modifier(Modifier::BOLD),
275                ),
276            ]);
277
278            // Create message lines
279            let message_lines: Vec<Line> = toast
280                .message
281                .lines()
282                .map(|line| {
283                    Line::from(Span::styled(
284                        line,
285                        Style::default().fg(Color::Rgb(200, 200, 200)),
286                    ))
287                })
288                .collect();
289
290            // Combine all lines
291            let mut content = vec![title_line];
292            content.extend(message_lines);
293
294            // Create the toast block
295            let block = Block::default()
296                .borders(Borders::ALL)
297                .border_type(BorderType::Rounded)
298                .border_style(Style::default().fg(dimmed_color))
299                .style(Style::default().bg(Color::Rgb(20, 20, 20)));
300
301            // Render the toast
302            let paragraph = Paragraph::new(content)
303                .block(block)
304                .alignment(Alignment::Left);
305
306            paragraph.render(toast_area, buf);
307
308            // Move down for next toast
309            y += height + 1;
310        }
311    }
312}
313
314// Helper trait to get RGB values from Color
315trait ColorRgb {
316    fn r(&self) -> u8;
317    fn g(&self) -> u8;
318    fn b(&self) -> u8;
319}
320
321impl ColorRgb for Color {
322    fn r(&self) -> u8 {
323        match self {
324            Color::Rgb(r, _, _) => *r,
325            _ => 0,
326        }
327    }
328
329    fn g(&self) -> u8 {
330        match self {
331            Color::Rgb(_, g, _) => *g,
332            _ => 0,
333        }
334    }
335
336    fn b(&self) -> u8 {
337        match self {
338            Color::Rgb(_, _, b) => *b,
339            _ => 0,
340        }
341    }
342}