ratatui_interact/components/
toast.rs1use ratatui::{
28 buffer::Buffer,
29 layout::{Alignment, Rect},
30 style::{Color, Style},
31 widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum ToastStyle {
37 #[default]
39 Info,
40 Success,
42 Warning,
44 Error,
46}
47
48impl ToastStyle {
49 pub fn border_color(&self) -> Color {
51 match self {
52 ToastStyle::Info => Color::Cyan,
53 ToastStyle::Success => Color::Green,
54 ToastStyle::Warning => Color::Yellow,
55 ToastStyle::Error => Color::Red,
56 }
57 }
58
59 pub fn from_message(message: &str) -> Self {
61 let lower = message.to_lowercase();
62 if lower.contains("error") || lower.contains("fail") {
63 ToastStyle::Error
64 } else if lower.contains("warning") || lower.contains("warn") {
65 ToastStyle::Warning
66 } else if lower.contains("success") || lower.contains("saved") || lower.contains("done") {
67 ToastStyle::Success
68 } else {
69 ToastStyle::Info
70 }
71 }
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct ToastState {
77 message: Option<String>,
79 expires_at: Option<i64>,
81}
82
83impl ToastState {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn show(&mut self, message: impl Into<String>, duration_ms: i64) {
91 let now = Self::current_time_ms();
92 self.message = Some(message.into());
93 self.expires_at = Some(now + duration_ms);
94 }
95
96 pub fn get_message(&self) -> Option<&str> {
98 if let (Some(msg), Some(expires)) = (&self.message, self.expires_at) {
99 let now = Self::current_time_ms();
100 if now < expires {
101 return Some(msg.as_str());
102 }
103 }
104 None
105 }
106
107 pub fn is_visible(&self) -> bool {
109 self.get_message().is_some()
110 }
111
112 pub fn clear_if_expired(&mut self) {
114 if let Some(expires) = self.expires_at {
115 let now = Self::current_time_ms();
116 if now >= expires {
117 self.message = None;
118 self.expires_at = None;
119 }
120 }
121 }
122
123 pub fn clear(&mut self) {
125 self.message = None;
126 self.expires_at = None;
127 }
128
129 fn current_time_ms() -> i64 {
131 std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .map(|d| d.as_millis() as i64)
134 .unwrap_or(0)
135 }
136}
137
138#[derive(Debug, Clone)]
142pub struct Toast<'a> {
143 message: &'a str,
144 style: ToastStyle,
145 auto_style: bool,
146 max_width: u16,
147 max_height: u16,
148 top_offset: u16,
149}
150
151impl<'a> Toast<'a> {
152 pub fn new(message: &'a str) -> Self {
154 Self {
155 message,
156 style: ToastStyle::Info,
157 auto_style: true,
158 max_width: 80,
159 max_height: 8,
160 top_offset: 3,
161 }
162 }
163
164 pub fn style(mut self, style: ToastStyle) -> Self {
168 self.style = style;
169 self.auto_style = false;
170 self
171 }
172
173 pub fn auto_style(mut self) -> Self {
175 self.auto_style = true;
176 self
177 }
178
179 pub fn max_width(mut self, width: u16) -> Self {
181 self.max_width = width;
182 self
183 }
184
185 pub fn max_height(mut self, height: u16) -> Self {
187 self.max_height = height;
188 self
189 }
190
191 pub fn top_offset(mut self, offset: u16) -> Self {
193 self.top_offset = offset;
194 self
195 }
196
197 pub fn calculate_area(&self, area: Rect) -> Rect {
199 let max_content_width = (area.width as usize)
201 .saturating_sub(8)
202 .min(self.max_width as usize);
203 let content_width = self.message.len() + 4; let toast_width = content_width.min(max_content_width).max(20) as u16;
205
206 let inner_width = toast_width.saturating_sub(2) as usize; let lines_needed = (self.message.len() + inner_width - 1) / inner_width.max(1);
209 let toast_height = (lines_needed as u16 + 2).min(self.max_height); let x = area.x + (area.width.saturating_sub(toast_width)) / 2;
213 let y = area.y
214 + self
215 .top_offset
216 .min(area.height.saturating_sub(toast_height));
217
218 Rect::new(x, y, toast_width, toast_height)
219 }
220
221 pub fn render_with_clear(self, area: Rect, buf: &mut Buffer) {
225 let toast_area = self.calculate_area(area);
226
227 Clear.render(toast_area, buf);
229
230 self.render_in_area(toast_area, buf);
232 }
233
234 fn render_in_area(self, area: Rect, buf: &mut Buffer) {
236 let border_color = if self.auto_style {
237 ToastStyle::from_message(self.message).border_color()
238 } else {
239 self.style.border_color()
240 };
241
242 let block = Block::default()
243 .borders(Borders::ALL)
244 .border_style(Style::default().fg(border_color))
245 .style(Style::default().bg(Color::Black));
246
247 let paragraph = Paragraph::new(self.message)
248 .block(block)
249 .wrap(Wrap { trim: true })
250 .alignment(Alignment::Center)
251 .style(Style::default().fg(Color::White));
252
253 paragraph.render(area, buf);
254 }
255}
256
257impl Widget for Toast<'_> {
258 fn render(self, area: Rect, buf: &mut Buffer) {
259 self.render_in_area(area, buf);
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_toast_state_new() {
271 let state = ToastState::new();
272 assert!(state.message.is_none());
273 assert!(state.expires_at.is_none());
274 }
275
276 #[test]
277 fn test_toast_state_lifecycle() {
278 let mut state = ToastState::new();
279
280 assert!(state.get_message().is_none());
282 assert!(!state.is_visible());
283
284 state.show("Test message", 100_000);
286 assert!(state.is_visible());
287 assert_eq!(state.get_message(), Some("Test message"));
288
289 state.clear();
291 assert!(!state.is_visible());
292 }
293
294 #[test]
295 fn test_toast_show_replaces_existing() {
296 let mut state = ToastState::new();
297
298 state.show("First message", 100_000);
299 assert_eq!(state.get_message(), Some("First message"));
300
301 state.show("Second message", 100_000);
302 assert_eq!(state.get_message(), Some("Second message"));
303 }
304
305 #[test]
306 fn test_toast_clear_if_expired() {
307 let mut state = ToastState::new();
308
309 state.show("Quick message", 0);
311 std::thread::sleep(std::time::Duration::from_millis(10));
312
313 state.clear_if_expired();
314 assert!(!state.is_visible());
315 }
316
317 #[test]
318 fn test_toast_style_detection() {
319 assert_eq!(
320 ToastStyle::from_message("error occurred"),
321 ToastStyle::Error
322 );
323 assert_eq!(ToastStyle::from_message("File saved"), ToastStyle::Success);
324 assert_eq!(
325 ToastStyle::from_message("Warning: low disk"),
326 ToastStyle::Warning
327 );
328 assert_eq!(ToastStyle::from_message("Hello world"), ToastStyle::Info);
329 }
330
331 #[test]
332 fn test_toast_style_detection_case_insensitive() {
333 assert_eq!(
334 ToastStyle::from_message("ERROR OCCURRED"),
335 ToastStyle::Error
336 );
337 assert_eq!(ToastStyle::from_message("FILE SAVED"), ToastStyle::Success);
338 assert_eq!(ToastStyle::from_message("WARNING"), ToastStyle::Warning);
339 }
340
341 #[test]
342 fn test_toast_style_detection_variants() {
343 assert_eq!(
344 ToastStyle::from_message("failed to load"),
345 ToastStyle::Error
346 );
347 assert_eq!(
348 ToastStyle::from_message("done processing"),
349 ToastStyle::Success
350 );
351 assert_eq!(
352 ToastStyle::from_message("warn: deprecated"),
353 ToastStyle::Warning
354 );
355 }
356
357 #[test]
358 fn test_toast_style_colors() {
359 assert_eq!(ToastStyle::Info.border_color(), Color::Cyan);
360 assert_eq!(ToastStyle::Success.border_color(), Color::Green);
361 assert_eq!(ToastStyle::Warning.border_color(), Color::Yellow);
362 assert_eq!(ToastStyle::Error.border_color(), Color::Red);
363 }
364
365 #[test]
366 fn test_toast_style_default() {
367 let style = ToastStyle::default();
368 assert_eq!(style, ToastStyle::Info);
369 }
370
371 #[test]
372 fn test_toast_area_calculation() {
373 let toast = Toast::new("Hello");
374 let area = Rect::new(0, 0, 100, 50);
375 let toast_area = toast.calculate_area(area);
376
377 assert!(toast_area.x > 0);
379 assert!(toast_area.x + toast_area.width <= area.width);
380
381 assert_eq!(toast_area.y, 3); }
384
385 #[test]
386 fn test_toast_area_calculation_long_message() {
387 let long_msg = "This is a very long message that should wrap to multiple lines";
388 let toast = Toast::new(long_msg);
389 let area = Rect::new(0, 0, 40, 20);
390 let toast_area = toast.calculate_area(area);
391
392 assert!(toast_area.width <= area.width);
394 }
395
396 #[test]
397 fn test_toast_area_calculation_custom_offset() {
398 let toast = Toast::new("Hello").top_offset(10);
399 let area = Rect::new(0, 0, 100, 50);
400 let toast_area = toast.calculate_area(area);
401
402 assert_eq!(toast_area.y, 10);
403 }
404
405 #[test]
406 fn test_toast_builder_methods() {
407 let toast = Toast::new("Test")
408 .style(ToastStyle::Error)
409 .max_width(60)
410 .max_height(5)
411 .top_offset(5);
412
413 assert_eq!(toast.style, ToastStyle::Error);
414 assert_eq!(toast.max_width, 60);
415 assert_eq!(toast.max_height, 5);
416 assert_eq!(toast.top_offset, 5);
417 assert!(!toast.auto_style); }
419
420 #[test]
421 fn test_toast_auto_style() {
422 let toast = Toast::new("Test").auto_style();
423 assert!(toast.auto_style);
424 }
425
426 #[test]
427 fn test_toast_render() {
428 let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
429 let toast = Toast::new("Test toast message");
430
431 toast.render_with_clear(Rect::new(0, 0, 60, 20), &mut buf);
432
433 let content: String = buf.content.iter().map(|c| c.symbol()).collect();
436 assert!(content.contains("Test"));
437 }
438
439 #[test]
440 fn test_toast_render_with_style() {
441 let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
442 let toast = Toast::new("Success!").style(ToastStyle::Success);
443
444 toast.render_with_clear(Rect::new(0, 0, 60, 20), &mut buf);
445
446 let content: String = buf.content.iter().map(|c| c.symbol()).collect();
447 assert!(content.contains("Success"));
448 }
449
450 #[test]
451 fn test_toast_widget_render() {
452 let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
453 let toast = Toast::new("Widget test");
454 let area = Rect::new(0, 0, 30, 5);
455
456 toast.render(area, &mut buf);
457 }
459}