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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ToastLevel {
11 Success,
12 Error,
13 Info,
14 Warning,
15}
16
17impl ToastLevel {
18 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 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#[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 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 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 pub fn is_expired(&self) -> bool {
75 self.created_at.elapsed() >= self.duration
76 }
77
78 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#[derive(Debug, Default)]
88pub struct ToastManager {
89 toasts: Vec<Toast>,
90 max_toasts: usize,
91}
92
93impl ToastManager {
94 pub fn new() -> Self {
96 Self {
97 toasts: Vec::new(),
98 max_toasts: 5, }
100 }
101
102 pub fn add(&mut self, toast: Toast) {
104 self.remove_expired();
106
107 self.toasts.push(toast);
109
110 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 pub fn success(&mut self, message: impl Into<String>) {
120 self.add(Toast::new(message, ToastLevel::Success));
121 }
122
123 pub fn error(&mut self, message: impl Into<String>) {
125 self.add(Toast::new(message, ToastLevel::Error));
126 }
127
128 pub fn info(&mut self, message: impl Into<String>) {
130 self.add(Toast::new(message, ToastLevel::Info));
131 }
132
133 pub fn warning(&mut self, message: impl Into<String>) {
135 self.add(Toast::new(message, ToastLevel::Warning));
136 }
137
138 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 pub fn get_active(&self) -> &[Toast] {
150 &self.toasts
151 }
152
153 pub fn has_toasts(&self) -> bool {
155 !self.toasts.is_empty()
156 }
157
158 pub fn clear(&mut self) {
160 self.toasts.clear();
161 }
162}
163
164pub 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 const TOAST_WIDTH: u16 = 40;
175 const TOAST_HEIGHT: u16 = 3; const TOAST_MARGIN: u16 = 2;
177 const TOAST_SPACING: u16 = 1;
178
179 let mut y_offset = area.height.saturating_sub(TOAST_MARGIN);
181
182 for toast in active_toasts.iter().rev() {
184 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 if toast_y == 0 || toast_x == 0 {
197 break;
198 }
199
200 frame.render_widget(Clear, toast_area);
202
203 let color = toast.level.color();
205 let icon = toast.level.icon();
206
207 let text = Line::from(vec![
208 Span::raw(" "), Span::styled(
210 icon,
211 Style::default().fg(color).add_modifier(Modifier::BOLD),
212 ),
213 Span::raw(" "), Span::raw(&toast.message),
215 Span::raw(" "), ]);
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 y_offset = toast_y.saturating_sub(TOAST_SPACING);
232 }
233}