use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use super::{Component, RenderContext};
const DEFAULT_MAX_VISIBLE: usize = 5;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ToastLevel {
#[default]
Info,
Success,
Warning,
Error,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ToastItem {
id: u64,
message: String,
level: ToastLevel,
remaining_ms: Option<u64>,
}
impl ToastItem {
pub fn id(&self) -> u64 {
self.id
}
pub fn message(&self) -> &str {
&self.message
}
pub fn level(&self) -> ToastLevel {
self.level
}
pub fn is_persistent(&self) -> bool {
self.remaining_ms.is_none()
}
pub fn remaining_ms(&self) -> Option<u64> {
self.remaining_ms
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ToastMessage {
Push {
message: String,
level: ToastLevel,
duration_ms: Option<u64>,
},
Dismiss(u64),
Clear,
Tick(u64),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToastOutput {
Added(u64),
Dismissed(u64),
Expired(Vec<u64>),
Cleared,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ToastState {
toasts: Vec<ToastItem>,
next_id: u64,
default_duration_ms: Option<u64>,
max_visible: usize,
}
impl Default for ToastState {
fn default() -> Self {
Self {
toasts: Vec::new(),
next_id: 0,
default_duration_ms: None,
max_visible: DEFAULT_MAX_VISIBLE,
}
}
}
impl ToastState {
pub fn new() -> Self {
Self::default()
}
pub fn with_duration(duration_ms: u64) -> Self {
Self {
default_duration_ms: Some(duration_ms),
..Self::default()
}
}
pub fn with_max_visible(max: usize) -> Self {
Self {
max_visible: max,
..Self::default()
}
}
pub fn toasts(&self) -> &[ToastItem] {
&self.toasts
}
pub fn len(&self) -> usize {
self.toasts.len()
}
pub fn is_empty(&self) -> bool {
self.toasts.is_empty()
}
pub fn default_duration(&self) -> Option<u64> {
self.default_duration_ms
}
pub fn max_visible(&self) -> usize {
self.max_visible
}
pub fn set_default_duration(&mut self, duration_ms: Option<u64>) {
self.default_duration_ms = duration_ms;
}
pub fn set_max_visible(&mut self, max: usize) {
self.max_visible = max;
}
fn push(&mut self, message: String, level: ToastLevel, duration_ms: Option<u64>) -> u64 {
let id = self.next_id;
self.next_id += 1;
let remaining_ms = match duration_ms {
Some(d) => Some(d),
None => self.default_duration_ms,
};
self.toasts.push(ToastItem {
id,
message,
level,
remaining_ms,
});
id
}
pub fn info(&mut self, message: impl Into<String>) -> u64 {
self.push(message.into(), ToastLevel::Info, None)
}
pub fn success(&mut self, message: impl Into<String>) -> u64 {
self.push(message.into(), ToastLevel::Success, None)
}
pub fn warning(&mut self, message: impl Into<String>) -> u64 {
self.push(message.into(), ToastLevel::Warning, None)
}
pub fn error(&mut self, message: impl Into<String>) -> u64 {
self.push(message.into(), ToastLevel::Error, None)
}
}
pub struct Toast;
impl Component for Toast {
type State = ToastState;
type Message = ToastMessage;
type Output = ToastOutput;
fn init() -> Self::State {
ToastState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
ToastMessage::Push {
message,
level,
duration_ms,
} => {
let id = state.push(message, level, duration_ms);
Some(ToastOutput::Added(id))
}
ToastMessage::Dismiss(id) => {
let len_before = state.toasts.len();
state.toasts.retain(|t| t.id != id);
if state.toasts.len() < len_before {
Some(ToastOutput::Dismissed(id))
} else {
None
}
}
ToastMessage::Clear => {
if state.toasts.is_empty() {
None
} else {
state.toasts.clear();
Some(ToastOutput::Cleared)
}
}
ToastMessage::Tick(elapsed_ms) => {
let mut expired_ids = Vec::new();
for toast in &mut state.toasts {
if let Some(remaining) = toast.remaining_ms.as_mut() {
if *remaining <= elapsed_ms {
expired_ids.push(toast.id);
} else {
*remaining -= elapsed_ms;
}
}
}
state.toasts.retain(|t| !expired_ids.contains(&t.id));
if expired_ids.is_empty() {
None
} else {
Some(ToastOutput::Expired(expired_ids))
}
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if state.toasts.is_empty() {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::toast("toast")
.with_meta("count", state.toasts.len().to_string()),
);
});
let toast_width = 40.min(ctx.area.width);
let toast_height = 3;
let visible_count = state.toasts.len().min(state.max_visible);
for (i, toast) in state.toasts.iter().rev().take(visible_count).enumerate() {
let y = ctx
.area
.bottom()
.saturating_sub((i as u16 + 1) * toast_height);
let x = ctx.area.right().saturating_sub(toast_width);
if y < ctx.area.y {
break; }
let toast_area = Rect::new(x, y, toast_width, toast_height.min(ctx.area.bottom() - y));
let (border_style, prefix) = match toast.level {
ToastLevel::Info => (ctx.theme.info_style(), "i"),
ToastLevel::Success => (ctx.theme.success_style(), "+"),
ToastLevel::Warning => (ctx.theme.warning_style(), "!"),
ToastLevel::Error => (ctx.theme.error_style(), "x"),
};
ctx.frame.render_widget(Clear, toast_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
let text = format!("[{}] {}", prefix, toast.message);
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
ctx.frame.render_widget(paragraph, toast_area);
}
}
}
#[cfg(test)]
mod tests;