use ratatui_core::layout::Rect;
use ratatui_core::style::{Color, Modifier, Style};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastLevel {
Info,
Success,
Warning,
Error,
}
impl ToastLevel {
pub fn color(self) -> Color {
match self {
ToastLevel::Info => Color::Rgb(90, 150, 210),
ToastLevel::Success => Color::Rgb(120, 190, 120),
ToastLevel::Warning => Color::Rgb(220, 180, 90),
ToastLevel::Error => Color::Rgb(220, 110, 110),
}
}
pub fn glyph(self) -> char {
match self {
ToastLevel::Info => 'ℹ',
ToastLevel::Success => '✔',
ToastLevel::Warning => '⚠',
ToastLevel::Error => '✖',
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Toast {
message: String,
level: ToastLevel,
remaining: Option<u64>,
}
pub const DEFAULT_TTL: u64 = 120;
#[derive(Clone, Debug, Default)]
pub struct Toasts {
items: Vec<Toast>,
max: usize,
}
impl Toasts {
pub fn new(max: usize) -> Self {
Self {
items: Vec::new(),
max: max.max(1),
}
}
pub fn push(&mut self, level: ToastLevel, message: impl Into<String>) {
self.push_for(level, message, Some(DEFAULT_TTL));
}
pub fn push_for(&mut self, level: ToastLevel, message: impl Into<String>, ttl: Option<u64>) {
self.items.insert(
0,
Toast {
message: message.into(),
level,
remaining: ttl,
},
);
self.items.truncate(self.max.max(1));
}
pub fn tick(&mut self) -> usize {
let before = self.items.len();
for t in &mut self.items {
if let Some(r) = t.remaining {
t.remaining = Some(r.saturating_sub(1));
}
}
self.items
.retain(|t| t.remaining.map(|r| r > 0).unwrap_or(true));
before - self.items.len()
}
pub fn dismiss_oldest(&mut self) {
self.items.pop();
}
pub fn clear(&mut self) {
self.items.clear();
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
pub struct ToastList<'a> {
toasts: &'a Toasts,
}
impl<'a> ToastList<'a> {
pub fn new(toasts: &'a Toasts) -> Self {
Self { toasts }
}
}
impl View for ToastList<'_> {
fn measure(&self, available: Size) -> Size {
let width = self
.toasts
.items
.iter()
.map(|t| crate::width::str_cols(&t.message).saturating_add(4))
.max()
.unwrap_or(0)
.min(available.width);
Size::new(
width,
(self.toasts.items.len() as u16).min(available.height),
)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.is_empty() {
return;
}
let fill = Style::default().fg(ctx.theme.text).bg(ctx.theme.surface);
for (row, toast) in self.toasts.items.iter().enumerate() {
let y = area.y.saturating_add(row as u16);
if y >= area.bottom() {
break;
}
for x in area.x..area.right() {
surface.set(x, y, ' ', fill);
}
let accent = Style::default()
.fg(toast.level.color())
.bg(ctx.theme.surface);
let mut x = area.x;
x = surface.set_string(x, y, "▌", accent.add_modifier(Modifier::BOLD));
x = surface.set_string(x, y, &format!("{} ", toast.level.glyph()), accent);
surface.set_string(x, y, &toast.message, fill);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Theme;
use crate::test_support::row;
#[test]
fn newest_pushes_to_the_top_and_max_evicts_oldest() {
let mut t = Toasts::new(2);
t.push(ToastLevel::Info, "first");
t.push(ToastLevel::Success, "second");
t.push(ToastLevel::Error, "third");
assert_eq!(t.len(), 2, "capped at max");
assert_eq!(t.items[0].message, "third");
assert_eq!(t.items[1].message, "second");
}
#[test]
fn tick_expires_after_ttl() {
let mut t = Toasts::new(4);
t.push_for(ToastLevel::Warning, "brief", Some(2));
t.push_for(ToastLevel::Info, "sticky", None);
assert_eq!(t.tick(), 0); assert_eq!(t.tick(), 1); assert_eq!(t.len(), 1, "the sticky toast persists");
for _ in 0..1000 {
t.tick();
}
assert_eq!(t.len(), 1);
}
#[test]
fn dismiss_and_clear() {
let mut t = Toasts::new(4);
t.push(ToastLevel::Info, "a");
t.push(ToastLevel::Info, "b");
t.dismiss_oldest(); assert_eq!(t.len(), 1);
assert_eq!(t.items[0].message, "b");
t.clear();
assert!(t.is_empty());
}
#[test]
fn renders_stack_with_level_colors() {
let theme = Theme::default();
let mut t = Toasts::new(4);
t.push(ToastLevel::Info, "saved");
t.push(ToastLevel::Error, "boom");
let buf = crate::testing::render(&ToastList::new(&t), 20, 2, &theme);
assert!(row(&buf, 0).contains("boom"));
assert!(row(&buf, 1).contains("saved"));
assert_eq!(
buf[(0, 0)].fg,
ToastLevel::Error.color(),
"bar uses level color"
);
}
#[test]
fn empty_and_tiny_areas_do_not_panic() {
let theme = Theme::default();
let empty = Toasts::new(4);
assert_eq!(ToastList::new(&empty).measure(Size::new(40, 10)).height, 0);
let mut t = Toasts::new(4);
t.push(ToastLevel::Info, "a very long notification message");
for (w, h) in [(0u16, 0u16), (1, 1), (3, 1)] {
let _ = crate::testing::render(&ToastList::new(&t), w, h, &theme);
}
}
}