use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style},
widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastStyle {
#[default]
Info,
Success,
Warning,
Error,
}
impl ToastStyle {
pub fn border_color(&self) -> Color {
match self {
ToastStyle::Info => Color::Cyan,
ToastStyle::Success => Color::Green,
ToastStyle::Warning => Color::Yellow,
ToastStyle::Error => Color::Red,
}
}
pub fn themed_border_color(&self, theme: &crate::theme::Theme) -> Color {
let p = &theme.palette;
match self {
ToastStyle::Info => p.info,
ToastStyle::Success => p.success,
ToastStyle::Warning => p.warning,
ToastStyle::Error => p.error,
}
}
pub fn from_message(message: &str) -> Self {
let lower = message.to_lowercase();
if lower.contains("error") || lower.contains("fail") {
ToastStyle::Error
} else if lower.contains("warning") || lower.contains("warn") {
ToastStyle::Warning
} else if lower.contains("success") || lower.contains("saved") || lower.contains("done") {
ToastStyle::Success
} else {
ToastStyle::Info
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ToastState {
message: Option<String>,
expires_at: Option<i64>,
}
impl ToastState {
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, message: impl Into<String>, duration_ms: i64) {
let now = Self::current_time_ms();
self.message = Some(message.into());
self.expires_at = Some(now + duration_ms);
}
pub fn get_message(&self) -> Option<&str> {
if let (Some(msg), Some(expires)) = (&self.message, self.expires_at) {
let now = Self::current_time_ms();
if now < expires {
return Some(msg.as_str());
}
}
None
}
pub fn is_visible(&self) -> bool {
self.get_message().is_some()
}
pub fn clear_if_expired(&mut self) {
if let Some(expires) = self.expires_at {
let now = Self::current_time_ms();
if now >= expires {
self.message = None;
self.expires_at = None;
}
}
}
pub fn clear(&mut self) {
self.message = None;
self.expires_at = None;
}
fn current_time_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub struct Toast<'a> {
message: &'a str,
style: ToastStyle,
auto_style: bool,
max_width: u16,
max_height: u16,
top_offset: u16,
}
impl<'a> Toast<'a> {
pub fn new(message: &'a str) -> Self {
Self {
message,
style: ToastStyle::Info,
auto_style: true,
max_width: 80,
max_height: 8,
top_offset: 3,
}
}
pub fn style(mut self, style: ToastStyle) -> Self {
self.style = style;
self.auto_style = false;
self
}
pub fn auto_style(mut self) -> Self {
self.auto_style = true;
self
}
pub fn max_width(mut self, width: u16) -> Self {
self.max_width = width;
self
}
pub fn max_height(mut self, height: u16) -> Self {
self.max_height = height;
self
}
pub fn top_offset(mut self, offset: u16) -> Self {
self.top_offset = offset;
self
}
pub fn calculate_area(&self, area: Rect) -> Rect {
let max_content_width = (area.width as usize)
.saturating_sub(8)
.min(self.max_width as usize);
let content_width = self.message.len() + 4; let toast_width = content_width.min(max_content_width).max(20) as u16;
let inner_width = toast_width.saturating_sub(2) as usize; let lines_needed = (self.message.len() + inner_width - 1) / inner_width.max(1);
let toast_height = (lines_needed as u16 + 2).min(self.max_height);
let x = area.x + (area.width.saturating_sub(toast_width)) / 2;
let y = area.y
+ self
.top_offset
.min(area.height.saturating_sub(toast_height));
Rect::new(x, y, toast_width, toast_height)
}
pub fn render_with_clear(self, area: Rect, buf: &mut Buffer) {
let toast_area = self.calculate_area(area);
Clear.render(toast_area, buf);
self.render_in_area(toast_area, buf);
}
fn render_in_area(self, area: Rect, buf: &mut Buffer) {
let border_color = if self.auto_style {
ToastStyle::from_message(self.message).border_color()
} else {
self.style.border_color()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(self.message)
.block(block)
.wrap(Wrap { trim: true })
.alignment(Alignment::Left)
.style(Style::default().fg(Color::White));
paragraph.render(area, buf);
}
}
impl Widget for Toast<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
self.render_in_area(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_toast_state_new() {
let state = ToastState::new();
assert!(state.message.is_none());
assert!(state.expires_at.is_none());
}
#[test]
fn test_toast_state_lifecycle() {
let mut state = ToastState::new();
assert!(state.get_message().is_none());
assert!(!state.is_visible());
state.show("Test message", 100_000);
assert!(state.is_visible());
assert_eq!(state.get_message(), Some("Test message"));
state.clear();
assert!(!state.is_visible());
}
#[test]
fn test_toast_show_replaces_existing() {
let mut state = ToastState::new();
state.show("First message", 100_000);
assert_eq!(state.get_message(), Some("First message"));
state.show("Second message", 100_000);
assert_eq!(state.get_message(), Some("Second message"));
}
#[test]
fn test_toast_clear_if_expired() {
let mut state = ToastState::new();
state.show("Quick message", 0);
std::thread::sleep(std::time::Duration::from_millis(10));
state.clear_if_expired();
assert!(!state.is_visible());
}
#[test]
fn test_toast_style_detection() {
assert_eq!(
ToastStyle::from_message("error occurred"),
ToastStyle::Error
);
assert_eq!(ToastStyle::from_message("File saved"), ToastStyle::Success);
assert_eq!(
ToastStyle::from_message("Warning: low disk"),
ToastStyle::Warning
);
assert_eq!(ToastStyle::from_message("Hello world"), ToastStyle::Info);
}
#[test]
fn test_toast_style_detection_case_insensitive() {
assert_eq!(
ToastStyle::from_message("ERROR OCCURRED"),
ToastStyle::Error
);
assert_eq!(ToastStyle::from_message("FILE SAVED"), ToastStyle::Success);
assert_eq!(ToastStyle::from_message("WARNING"), ToastStyle::Warning);
}
#[test]
fn test_toast_style_detection_variants() {
assert_eq!(
ToastStyle::from_message("failed to load"),
ToastStyle::Error
);
assert_eq!(
ToastStyle::from_message("done processing"),
ToastStyle::Success
);
assert_eq!(
ToastStyle::from_message("warn: deprecated"),
ToastStyle::Warning
);
}
#[test]
fn test_toast_style_colors() {
assert_eq!(ToastStyle::Info.border_color(), Color::Cyan);
assert_eq!(ToastStyle::Success.border_color(), Color::Green);
assert_eq!(ToastStyle::Warning.border_color(), Color::Yellow);
assert_eq!(ToastStyle::Error.border_color(), Color::Red);
}
#[test]
fn test_toast_style_default() {
let style = ToastStyle::default();
assert_eq!(style, ToastStyle::Info);
}
#[test]
fn test_toast_area_calculation() {
let toast = Toast::new("Hello");
let area = Rect::new(0, 0, 100, 50);
let toast_area = toast.calculate_area(area);
assert!(toast_area.x > 0);
assert!(toast_area.x + toast_area.width <= area.width);
assert_eq!(toast_area.y, 3); }
#[test]
fn test_toast_area_calculation_long_message() {
let long_msg = "This is a very long message that should wrap to multiple lines";
let toast = Toast::new(long_msg);
let area = Rect::new(0, 0, 40, 20);
let toast_area = toast.calculate_area(area);
assert!(toast_area.width <= area.width);
}
#[test]
fn test_toast_area_calculation_custom_offset() {
let toast = Toast::new("Hello").top_offset(10);
let area = Rect::new(0, 0, 100, 50);
let toast_area = toast.calculate_area(area);
assert_eq!(toast_area.y, 10);
}
#[test]
fn test_toast_builder_methods() {
let toast = Toast::new("Test")
.style(ToastStyle::Error)
.max_width(60)
.max_height(5)
.top_offset(5);
assert_eq!(toast.style, ToastStyle::Error);
assert_eq!(toast.max_width, 60);
assert_eq!(toast.max_height, 5);
assert_eq!(toast.top_offset, 5);
assert!(!toast.auto_style); }
#[test]
fn test_toast_auto_style() {
let toast = Toast::new("Test").auto_style();
assert!(toast.auto_style);
}
#[test]
fn test_toast_render() {
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
let toast = Toast::new("Test toast message");
toast.render_with_clear(Rect::new(0, 0, 60, 20), &mut buf);
let content: String = buf.content.iter().map(|c| c.symbol()).collect();
assert!(content.contains("Test"));
}
#[test]
fn test_toast_render_with_style() {
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
let toast = Toast::new("Success!").style(ToastStyle::Success);
toast.render_with_clear(Rect::new(0, 0, 60, 20), &mut buf);
let content: String = buf.content.iter().map(|c| c.symbol()).collect();
assert!(content.contains("Success"));
}
#[test]
fn test_toast_widget_render() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let toast = Toast::new("Widget test");
let area = Rect::new(0, 0, 30, 5);
toast.render(area, &mut buf);
}
}