1use 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
17const MAX_TOASTS: usize = 3;
19
20const TOAST_DURATION: Duration = Duration::from_secs(5);
22
23#[derive(Debug, Clone)]
25pub struct Toast {
26 pub message: String,
28 pub kind: ToastKind,
30 pub created_at: Instant,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ToastKind {
37 Error,
39 #[allow(dead_code)]
41 Warning,
42 #[allow(dead_code)]
44 Info,
45 #[allow(dead_code)]
47 Success,
48}
49
50impl ToastKind {
51 fn color(&self) -> Color {
53 match self {
54 ToastKind::Error => Color::Rgb(239, 68, 68), ToastKind::Warning => Color::Rgb(245, 158, 11), ToastKind::Info => Color::Rgb(59, 130, 246), ToastKind::Success => Color::Rgb(34, 197, 94), }
59 }
60
61 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 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
82pub struct ToasterState {
84 toasts: Vec<Toast>,
86}
87
88impl Default for ToasterState {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl ToasterState {
95 pub fn new() -> Self {
97 Self { toasts: Vec::new() }
98 }
99
100 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 if self.toasts.len() > MAX_TOASTS {
112 self.toasts.drain(0..self.toasts.len() - MAX_TOASTS);
113 }
114 }
115
116 pub fn error(&mut self, message: impl Into<String>) {
118 self.add_toast(message.into(), ToastKind::Error);
119 }
120
121 #[allow(dead_code)]
123 pub fn warning(&mut self, message: impl Into<String>) {
124 self.add_toast(message.into(), ToastKind::Warning);
125 }
126
127 #[allow(dead_code)]
129 pub fn info(&mut self, message: impl Into<String>) {
130 self.add_toast(message.into(), ToastKind::Info);
131 }
132
133 #[allow(dead_code)]
135 pub fn success(&mut self, message: impl Into<String>) {
136 self.add_toast(message.into(), ToastKind::Success);
137 }
138
139 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 pub fn has_toasts(&self) -> bool {
148 !self.toasts.is_empty()
149 }
150
151 pub fn toasts(&self) -> &[Toast] {
153 &self.toasts
154 }
155
156 #[allow(dead_code)]
158 pub fn clear(&mut self) {
159 self.toasts.clear();
160 }
161}
162
163pub struct Toaster<'a> {
165 state: &'a ToasterState,
166}
167
168impl<'a> Toaster<'a> {
169 pub fn new(state: &'a ToasterState) -> Self {
171 Self { state }
172 }
173
174 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 fn calculate_width(&self) -> u16 {
184 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 (max_message_len + 20).min(60) as u16
195 }
196
197 fn calculate_toast_height(&self, toast: &Toast, width: u16) -> u16 {
199 let content_width = width.saturating_sub(4) as usize; let lines = toast.message.len().div_ceil(content_width);
202 (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 for (idx, toast) in self.state.toasts().iter().enumerate() {
218 let height = self.calculate_toast_height(toast, width);
219
220 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 let age_factor = {
234 let elapsed = toast.created_at.elapsed();
235 if elapsed >= TOAST_DURATION.mul_f32(0.8) {
236 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 let position_factor = 1.0 - (idx as f32 * 0.2);
248 let opacity_factor = (age_factor * position_factor).max(0.3);
249
250 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 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 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 let mut content = vec![title_line];
292 content.extend(message_lines);
293
294 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 let paragraph = Paragraph::new(content)
303 .block(block)
304 .alignment(Alignment::Left);
305
306 paragraph.render(toast_area, buf);
307
308 y += height + 1;
310 }
311 }
312}
313
314trait 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}