ratatui_toolkit/toast/
mod.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ToastLevel {
15 Success,
16 Error,
17 Info,
18 Warning,
19}
20
21impl ToastLevel {
22 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 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#[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 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 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 pub fn is_expired(&self) -> bool {
79 self.created_at.elapsed() >= self.duration
80 }
81
82 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#[derive(Debug, Default)]
92pub struct ToastManager {
93 toasts: Vec<Toast>,
94 max_toasts: usize,
95}
96
97impl ToastManager {
98 pub fn new() -> Self {
100 Self {
101 toasts: Vec::new(),
102 max_toasts: 5,
103 }
104 }
105
106 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 pub fn success(&mut self, message: impl Into<String>) {
121 self.add(Toast::new(message, ToastLevel::Success));
122 }
123
124 pub fn error(&mut self, message: impl Into<String>) {
126 self.add(Toast::new(message, ToastLevel::Error));
127 }
128
129 pub fn info(&mut self, message: impl Into<String>) {
131 self.add(Toast::new(message, ToastLevel::Info));
132 }
133
134 pub fn warning(&mut self, message: impl Into<String>) {
136 self.add(Toast::new(message, ToastLevel::Warning));
137 }
138
139 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 pub fn get_active(&self) -> &[Toast] {
151 &self.toasts
152 }
153
154 pub fn has_toasts(&self) -> bool {
156 !self.toasts.is_empty()
157 }
158
159 pub fn clear(&mut self) {
161 self.toasts.clear();
162 }
163}
164
165pub 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}