1use std::time::{Duration, Instant};
19
20use ratatui::{
21 buffer::Buffer,
22 layout::Rect,
23 style::{Color, Modifier, Style},
24 text::{Line, Span},
25 widgets::{Paragraph, Widget},
26};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ToastLevel {
31 Info,
33 Warn,
35 Error,
37}
38
39#[derive(Debug, Clone)]
41pub struct Toast {
42 pub message: String,
44 pub level: ToastLevel,
46 pub expires_at: Instant,
49}
50
51impl Toast {
52 #[must_use]
55 pub fn style(&self) -> Style {
56 let fg = match self.level {
57 ToastLevel::Info => Color::Green,
58 ToastLevel::Warn => Color::Yellow,
59 ToastLevel::Error => Color::Red,
60 };
61 Style::default().fg(fg).add_modifier(Modifier::BOLD)
62 }
63}
64
65#[derive(Debug)]
70pub struct ToastQueue {
71 toasts: Vec<Toast>,
72 max_visible: usize,
73}
74
75impl Default for ToastQueue {
76 fn default() -> Self {
77 Self {
78 toasts: Vec::new(),
79 max_visible: 3,
80 }
81 }
82}
83
84impl ToastQueue {
85 pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(3);
87
88 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
97 self.push_with_lifetime(message, level, Self::DEFAULT_LIFETIME);
98 }
99
100 pub fn push_with_lifetime(
103 &mut self,
104 message: impl Into<String>,
105 level: ToastLevel,
106 lifetime: Duration,
107 ) {
108 let toast = Toast {
109 message: message.into(),
110 level,
111 expires_at: Instant::now() + lifetime,
112 };
113 if self.toasts.len() >= self.max_visible {
114 self.toasts.remove(0);
115 }
116 self.toasts.push(toast);
117 }
118
119 pub fn tick(&mut self) {
123 let now = Instant::now();
124 self.toasts.retain(|t| t.expires_at > now);
125 }
126
127 #[must_use]
132 #[allow(clippy::missing_const_for_fn)]
133 pub fn visible(&self) -> &[Toast] {
134 &self.toasts
135 }
136
137 #[must_use]
139 pub const fn visible_count(&self) -> usize {
140 self.toasts.len()
141 }
142
143 #[must_use]
145 pub const fn is_empty(&self) -> bool {
146 self.toasts.is_empty()
147 }
148}
149
150pub fn render_toasts(queue: &ToastQueue, area: Rect, buf: &mut Buffer) {
156 if area.width == 0 || area.height == 0 {
157 return;
158 }
159 for (i, toast) in queue.visible().iter().rev().enumerate() {
161 let offset = u16::try_from(i).unwrap_or(u16::MAX);
162 if offset >= area.height {
163 break;
164 }
165 let row = Rect {
166 x: area.x,
167 y: area.y + offset,
168 width: area.width,
169 height: 1,
170 };
171 let prefix = match toast.level {
172 ToastLevel::Info => "i ",
173 ToastLevel::Warn => "! ",
174 ToastLevel::Error => "x ",
175 };
176 let style = toast.style();
177 let line = Line::from(vec![
178 Span::styled(prefix, style),
179 Span::styled(toast.message.as_str(), style),
180 ]);
181 Paragraph::new(line).render(row, buf);
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use std::thread;
189
190 #[test]
191 fn queue_starts_empty() {
192 let q = ToastQueue::new();
193 assert_eq!(q.visible_count(), 0);
194 assert!(q.is_empty());
195 }
196
197 #[test]
198 fn push_adds_toast() {
199 let mut q = ToastQueue::new();
200 q.push("hello", ToastLevel::Info);
201 assert_eq!(q.visible_count(), 1);
202 assert_eq!(q.visible()[0].message, "hello");
203 assert_eq!(q.visible()[0].level, ToastLevel::Info);
204 }
205
206 #[test]
207 fn tick_removes_expired_toasts() {
208 let mut q = ToastQueue::new();
209 q.push_with_lifetime("short", ToastLevel::Info, Duration::from_millis(10));
210 assert_eq!(q.visible_count(), 1);
211 thread::sleep(Duration::from_millis(25));
212 q.tick();
213 assert_eq!(q.visible_count(), 0);
214 assert!(q.is_empty());
215 }
216
217 #[test]
218 fn tick_keeps_live_toasts() {
219 let mut q = ToastQueue::new();
220 q.push_with_lifetime("live", ToastLevel::Info, Duration::from_secs(60));
221 q.tick();
222 assert_eq!(q.visible_count(), 1);
223 }
224
225 #[test]
226 fn queue_drops_oldest_when_full() {
227 let mut q = ToastQueue::new();
228 for i in 0..4 {
230 q.push(format!("t{i}"), ToastLevel::Info);
231 }
232 assert_eq!(q.visible_count(), 3);
233 let msgs: Vec<&str> = q.visible().iter().map(|t| t.message.as_str()).collect();
234 assert_eq!(msgs, vec!["t1", "t2", "t3"]);
235 }
236
237 #[test]
238 fn default_lifetime_is_3_seconds() {
239 assert_eq!(ToastQueue::DEFAULT_LIFETIME, Duration::from_secs(3));
240 }
241
242 #[test]
243 fn level_colours_are_distinct() {
244 let now = Instant::now();
245 let info = Toast {
246 message: String::new(),
247 level: ToastLevel::Info,
248 expires_at: now,
249 };
250 let warn = Toast {
251 message: String::new(),
252 level: ToastLevel::Warn,
253 expires_at: now,
254 };
255 let err = Toast {
256 message: String::new(),
257 level: ToastLevel::Error,
258 expires_at: now,
259 };
260 assert_ne!(info.style().fg, warn.style().fg);
261 assert_ne!(warn.style().fg, err.style().fg);
262 assert_ne!(info.style().fg, err.style().fg);
263 }
264
265 #[test]
266 fn level_styles_are_bold() {
267 let t = Toast {
268 message: String::new(),
269 level: ToastLevel::Info,
270 expires_at: Instant::now(),
271 };
272 assert!(t.style().add_modifier.contains(Modifier::BOLD));
273 }
274
275 #[test]
276 fn render_noop_on_zero_area() {
277 let mut q = ToastQueue::new();
278 q.push("hello", ToastLevel::Info);
279 let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
280 render_toasts(&q, Rect::new(0, 0, 0, 0), &mut buf);
281 }
283
284 #[test]
285 fn render_writes_newest_toast_at_top() {
286 let mut q = ToastQueue::new();
287 q.push("first", ToastLevel::Info);
288 q.push("second", ToastLevel::Warn);
289 let area = Rect::new(0, 0, 20, 3);
290 let mut buf = Buffer::empty(area);
291 render_toasts(&q, area, &mut buf);
292 let top = buf_row_string(&buf, 0);
294 assert!(top.starts_with("! second"), "got: {top:?}");
295 let row1 = buf_row_string(&buf, 1);
297 assert!(row1.starts_with("i first"), "got: {row1:?}");
298 }
299
300 fn buf_row_string(buf: &Buffer, y: u16) -> String {
301 let area = buf.area;
302 (0..area.width)
303 .map(|x| buf[(area.x + x, area.y + y)].symbol().to_string())
304 .collect::<String>()
305 }
306}