ratatui_toolkit/toast/
mod.rs

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