use std::time::{Duration, Instant};
use ratatui::{
Frame,
layout::Rect,
style::Modifier,
widgets::{Block, BorderType, Borders, Clear, Paragraph},
};
use super::{chrome, style};
use crate::text;
use crate::theme::{Color, Skin};
const DEFAULT_TTL: Duration = Duration::from_secs(4);
const TOAST_WIDTH: u16 = 32;
const BORDER_ROWS: u16 = 2;
const MAX_TOAST_LINES: usize = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Warning,
Error,
}
impl ToastKind {
fn color(self, skin: &Skin) -> Color {
match self {
ToastKind::Info => skin.palette.info,
ToastKind::Success => skin.palette.success,
ToastKind::Warning => skin.palette.warning,
ToastKind::Error => skin.palette.error,
}
}
fn label(self) -> &'static str {
match self {
ToastKind::Info => "info",
ToastKind::Success => "success",
ToastKind::Warning => "warning",
ToastKind::Error => "error",
}
}
}
struct Toast {
kind: ToastKind,
message: String,
expires: Instant,
}
#[derive(Default)]
pub struct Toasts {
items: Vec<Toast>,
}
impl Toasts {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, kind: ToastKind, message: impl Into<String>) {
self.push_with_ttl(kind, message, DEFAULT_TTL);
}
pub fn push_with_ttl(
&mut self,
kind: ToastKind,
message: impl Into<String>,
ttl: Duration,
) {
self.items.push(Toast {
kind,
message: message.into(),
expires: Instant::now() + ttl,
});
}
pub fn prune(&mut self) {
let now = Instant::now();
self.items.retain(|toast| toast.expires > now);
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let now = Instant::now();
let width = TOAST_WIDTH.min(area.width);
let bottom = area.y + area.height;
let mut y = area.y;
for toast in self.items.iter().filter(|t| t.expires > now) {
let lines = body_lines(&toast.message, width);
let wanted = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let height = wanted
.saturating_add(BORDER_ROWS)
.min(bottom.saturating_sub(y));
if height <= BORDER_ROWS {
break;
}
let rect = Rect {
x: area.x + area.width.saturating_sub(width),
y,
width,
height,
};
let color = toast.kind.color(skin);
let label = style::fg(color).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(style::fg(color))
.style(style::bg(skin.palette.background))
.title(chrome::border_title(skin, toast.kind.label(), label));
frame.render_widget(Clear, rect);
let body = Paragraph::new(lines.join("\n")).block(block);
frame.render_widget(body, rect);
y += height;
}
}
}
fn body_lines(message: &str, width: u16) -> Vec<String> {
let inner = usize::from(width.saturating_sub(BORDER_ROWS));
let mut lines = text::wrap(message, inner);
if lines.len() <= MAX_TOAST_LINES {
return lines;
}
lines.truncate(MAX_TOAST_LINES);
if let Some(last) = lines.last_mut() {
*last = text::truncate(&format!("{last}\u{2026}"), inner);
}
lines
}
#[cfg(test)]
mod tests {
use unicode_width::UnicodeWidthStr;
use super::*;
const INNER: usize = (TOAST_WIDTH - BORDER_ROWS) as usize;
#[test]
fn a_short_message_stays_on_one_line() {
assert_eq!(body_lines("saved", TOAST_WIDTH), vec!["saved"]);
}
#[test]
fn a_long_message_wraps_into_the_inner_width() {
let message = "switch to custom order (t) to reorder";
let lines = body_lines(message, TOAST_WIDTH);
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|line| line.width() <= INNER));
assert_eq!(lines.join(" "), message, "no word is lost");
}
#[test]
fn a_very_long_message_is_capped_with_an_ellipsis() {
let message = "word ".repeat(100);
let lines = body_lines(&message, TOAST_WIDTH);
assert_eq!(lines.len(), MAX_TOAST_LINES);
let last = lines.last().expect("the capped body has lines");
assert!(last.ends_with('\u{2026}'), "last line: {last}");
assert!(last.width() <= INNER);
}
#[test]
fn a_wide_glyph_counts_as_two_columns() {
let lines = body_lines(&"δΈ–".repeat(20), TOAST_WIDTH);
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|line| line.width() <= INNER));
}
#[test]
fn prune_drops_expired_but_keeps_fresh() {
let mut toasts = Toasts::new();
toasts.push_with_ttl(ToastKind::Error, "boom", Duration::ZERO);
toasts.push(ToastKind::Info, "hello");
toasts.prune();
assert!(!toasts.is_empty()); }
#[test]
fn prune_empties_when_all_expired() {
let mut toasts = Toasts::new();
toasts.push_with_ttl(ToastKind::Warning, "x", Duration::ZERO);
toasts.prune();
assert!(toasts.is_empty());
}
}