use std::time::{Duration, Instant};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastLevel {
Info,
Warn,
Error,
}
#[derive(Debug, Clone)]
pub struct Toast {
pub message: String,
pub level: ToastLevel,
pub expires_at: Instant,
}
impl Toast {
#[must_use]
pub fn style(&self) -> Style {
let fg = match self.level {
ToastLevel::Info => Color::Green,
ToastLevel::Warn => Color::Yellow,
ToastLevel::Error => Color::Red,
};
Style::default().fg(fg).add_modifier(Modifier::BOLD)
}
}
#[derive(Debug)]
pub struct ToastQueue {
toasts: Vec<Toast>,
max_visible: usize,
}
impl Default for ToastQueue {
fn default() -> Self {
Self {
toasts: Vec::new(),
max_visible: 3,
}
}
}
impl ToastQueue {
pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(3);
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
self.push_with_lifetime(message, level, Self::DEFAULT_LIFETIME);
}
pub fn push_with_lifetime(
&mut self,
message: impl Into<String>,
level: ToastLevel,
lifetime: Duration,
) {
let toast = Toast {
message: message.into(),
level,
expires_at: Instant::now() + lifetime,
};
if self.toasts.len() >= self.max_visible {
self.toasts.remove(0);
}
self.toasts.push(toast);
}
pub fn tick(&mut self) {
let now = Instant::now();
self.toasts.retain(|t| t.expires_at > now);
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn visible(&self) -> &[Toast] {
&self.toasts
}
#[must_use]
pub const fn visible_count(&self) -> usize {
self.toasts.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.toasts.is_empty()
}
}
pub fn render_toasts(queue: &ToastQueue, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
for (i, toast) in queue.visible().iter().rev().enumerate() {
let offset = u16::try_from(i).unwrap_or(u16::MAX);
if offset >= area.height {
break;
}
let row = Rect {
x: area.x,
y: area.y + offset,
width: area.width,
height: 1,
};
let prefix = match toast.level {
ToastLevel::Info => "i ",
ToastLevel::Warn => "! ",
ToastLevel::Error => "x ",
};
let style = toast.style();
let line = Line::from(vec![
Span::styled(prefix, style),
Span::styled(toast.message.as_str(), style),
]);
Paragraph::new(line).render(row, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn queue_starts_empty() {
let q = ToastQueue::new();
assert_eq!(q.visible_count(), 0);
assert!(q.is_empty());
}
#[test]
fn push_adds_toast() {
let mut q = ToastQueue::new();
q.push("hello", ToastLevel::Info);
assert_eq!(q.visible_count(), 1);
assert_eq!(q.visible()[0].message, "hello");
assert_eq!(q.visible()[0].level, ToastLevel::Info);
}
#[test]
fn tick_removes_expired_toasts() {
let mut q = ToastQueue::new();
q.push_with_lifetime("short", ToastLevel::Info, Duration::from_millis(10));
assert_eq!(q.visible_count(), 1);
thread::sleep(Duration::from_millis(25));
q.tick();
assert_eq!(q.visible_count(), 0);
assert!(q.is_empty());
}
#[test]
fn tick_keeps_live_toasts() {
let mut q = ToastQueue::new();
q.push_with_lifetime("live", ToastLevel::Info, Duration::from_secs(60));
q.tick();
assert_eq!(q.visible_count(), 1);
}
#[test]
fn queue_drops_oldest_when_full() {
let mut q = ToastQueue::new();
for i in 0..4 {
q.push(format!("t{i}"), ToastLevel::Info);
}
assert_eq!(q.visible_count(), 3);
let msgs: Vec<&str> = q.visible().iter().map(|t| t.message.as_str()).collect();
assert_eq!(msgs, vec!["t1", "t2", "t3"]);
}
#[test]
fn default_lifetime_is_3_seconds() {
assert_eq!(ToastQueue::DEFAULT_LIFETIME, Duration::from_secs(3));
}
#[test]
fn level_colours_are_distinct() {
let now = Instant::now();
let info = Toast {
message: String::new(),
level: ToastLevel::Info,
expires_at: now,
};
let warn = Toast {
message: String::new(),
level: ToastLevel::Warn,
expires_at: now,
};
let err = Toast {
message: String::new(),
level: ToastLevel::Error,
expires_at: now,
};
assert_ne!(info.style().fg, warn.style().fg);
assert_ne!(warn.style().fg, err.style().fg);
assert_ne!(info.style().fg, err.style().fg);
}
#[test]
fn level_styles_are_bold() {
let t = Toast {
message: String::new(),
level: ToastLevel::Info,
expires_at: Instant::now(),
};
assert!(t.style().add_modifier.contains(Modifier::BOLD));
}
#[test]
fn render_noop_on_zero_area() {
let mut q = ToastQueue::new();
q.push("hello", ToastLevel::Info);
let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
render_toasts(&q, Rect::new(0, 0, 0, 0), &mut buf);
}
#[test]
fn render_writes_newest_toast_at_top() {
let mut q = ToastQueue::new();
q.push("first", ToastLevel::Info);
q.push("second", ToastLevel::Warn);
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
render_toasts(&q, area, &mut buf);
let top = buf_row_string(&buf, 0);
assert!(top.starts_with("! second"), "got: {top:?}");
let row1 = buf_row_string(&buf, 1);
assert!(row1.starts_with("i first"), "got: {row1:?}");
}
fn buf_row_string(buf: &Buffer, y: u16) -> String {
let area = buf.area;
(0..area.width)
.map(|x| buf[(area.x + x, area.y + y)].symbol().to_string())
.collect::<String>()
}
}