#![warn(missing_docs)]
use std::time::Duration;
use web_time::Instant;
use crate::state::UiState;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::button::button;
pub const DEFAULT_TOAST_TTL: Duration = Duration::from_secs(4);
pub const MAX_VISIBLE_TOASTS: usize = 3;
pub const MAX_QUEUED_TOASTS: usize = 64;
pub const TOAST_EXIT_WINDOW: Duration = Duration::from_millis(200);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastLevel {
Default,
Success,
Warning,
Error,
Info,
}
#[derive(Clone, Debug)]
pub struct ToastSpec {
pub level: ToastLevel,
pub message: String,
pub ttl: Duration,
}
impl ToastSpec {
pub fn new(level: ToastLevel, message: impl Into<String>) -> Self {
Self {
level,
message: message.into(),
ttl: DEFAULT_TOAST_TTL,
}
}
pub fn default(message: impl Into<String>) -> Self {
Self::new(ToastLevel::Default, message)
}
pub fn success(message: impl Into<String>) -> Self {
Self::new(ToastLevel::Success, message)
}
pub fn warning(message: impl Into<String>) -> Self {
Self::new(ToastLevel::Warning, message)
}
pub fn error(message: impl Into<String>) -> Self {
Self::new(ToastLevel::Error, message)
}
pub fn info(message: impl Into<String>) -> Self {
Self::new(ToastLevel::Info, message)
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
}
#[derive(Clone, Debug)]
pub struct Toast {
pub id: u64,
pub level: ToastLevel,
pub message: String,
pub expires_at: Instant,
pub dismissed: bool,
pub exit_started: Option<Instant>,
}
pub fn synthesize_toasts(root: &mut El, ui_state: &mut UiState, now: Instant) -> bool {
for t in ui_state.toast.queue.iter_mut() {
if t.exit_started.is_none() && (t.dismissed || t.expires_at <= now) {
t.exit_started = Some(now);
}
}
ui_state.toast.queue.retain(|t| {
t.exit_started
.is_none_or(|s| now.duration_since(s) < TOAST_EXIT_WINDOW)
});
if ui_state.toast.queue.is_empty() {
return false;
}
debug_assert_eq!(
root.axis,
Axis::Overlay,
"synthesize_toasts: root must be an Axis::Overlay container so the toast \
stack overlays the main view. Wrap your `App::build` return value in \
`overlays(main, [])`. Got axis = {:?}",
root.axis,
);
let visible_from = ui_state
.toast
.queue
.len()
.saturating_sub(MAX_VISIBLE_TOASTS);
let cards: Vec<El> = ui_state.toast.queue[visible_from..]
.iter()
.map(toast_card)
.collect();
root.children.push(toast_stack(cards));
let i = root.children.len() - 1;
crate::layout::assign_id_appended(&root.computed_id, &mut root.children[i], i);
true
}
fn toast_stack(cards: Vec<El>) -> El {
El::new(Kind::Custom("toast_stack"))
.children(cards)
.fill_size()
.layout(|ctx| {
let viewport = (ctx.rect_of_id)("root").unwrap_or(ctx.container);
let pad = tokens::SPACE_4;
let gap = tokens::SPACE_2;
let mut rects = Vec::with_capacity(ctx.children.len());
let mut bottom = viewport.bottom() - pad;
for c in ctx.children.iter().rev() {
let (w, h) = (ctx.measure)(c);
let x = viewport.right() - w - pad;
rects.push(Rect::new(x, bottom - h, w, h));
bottom -= h + gap;
}
rects.reverse();
rects
})
}
fn toast_card(t: &Toast) -> El {
let accent = level_accent(t.level);
let lead = El::new(Kind::Group)
.width(Size::Fixed(3.0))
.height(Size::Fill(1.0))
.fill(accent)
.radius(tokens::RADIUS_SM);
let body = El::new(Kind::Text)
.text(t.message.clone())
.text_role(TextRole::Body)
.text_color(tokens::FOREGROUND)
.text_wrap(TextWrap::Wrap)
.width(Size::Fill(1.0));
let dismiss = button("×")
.key(format!("toast-dismiss-{}", t.id))
.secondary();
let card = El::new(Kind::Custom("toast_card"))
.key(format!("toast-{}", t.id))
.style_profile(StyleProfile::Surface)
.surface_role(SurfaceRole::Popover)
.axis(Axis::Row)
.align(Align::Stretch)
.gap(tokens::SPACE_2)
.padding(tokens::SPACE_3)
.fill(tokens::POPOVER)
.stroke(tokens::BORDER)
.radius(tokens::RADIUS_MD)
.shadow(tokens::SHADOW_MD)
.enter_transition(
crate::anim::EnterTransition::fade()
.with_slide(0.0, 16.0)
.with_timing(crate::anim::Timing::SPRING_STANDARD),
)
.width(Size::Fixed(360.0))
.height(Size::Hug)
.children([lead, body, dismiss]);
if t.exit_started.is_some() {
card.opacity(0.0)
.translate(0.0, 16.0)
.animate(crate::anim::Timing::EASE_STANDARD)
} else {
card
}
}
fn level_accent(level: ToastLevel) -> Color {
match level {
ToastLevel::Default => tokens::INPUT,
ToastLevel::Success => tokens::SUCCESS,
ToastLevel::Warning => tokens::WARNING,
ToastLevel::Error => tokens::DESTRUCTIVE,
ToastLevel::Info => tokens::INFO,
}
}
pub fn parse_dismiss_key(key: &str) -> Option<u64> {
key.strip_prefix("toast-dismiss-")
.and_then(|rest| rest.parse::<u64>().ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::{assign_ids, layout};
#[test]
fn synthesize_appends_layer_per_active_toast() {
let mut tree = crate::stack(std::iter::empty::<El>());
let mut state = UiState::new();
let now = Instant::now();
state.push_toast(ToastSpec::success("Saved"), now);
state.push_toast(ToastSpec::error("Failed"), now);
assign_ids(&mut tree);
let pending = synthesize_toasts(&mut tree, &mut state, now);
assert!(pending, "active toasts → caller should request redraw");
let stack = tree.children.last().expect("toast_stack appended to root");
assert!(matches!(stack.kind, Kind::Custom("toast_stack")));
assert_eq!(stack.children.len(), 2);
}
#[test]
fn expired_toast_exits_through_the_window_not_instantly() {
let mut tree = crate::stack(std::iter::empty::<El>());
let mut state = UiState::new();
let t0 = Instant::now();
state.push_toast(
ToastSpec::info("old").with_ttl(Duration::from_millis(10)),
t0,
);
state.push_toast(ToastSpec::info("new").with_ttl(Duration::from_secs(60)), t0);
assign_ids(&mut tree);
let later = t0 + Duration::from_secs(1);
assert!(synthesize_toasts(&mut tree, &mut state, later));
assert_eq!(state.toast.queue.len(), 2, "expired toast exits, not drops");
assert_eq!(state.toast.queue[0].exit_started, Some(later));
let stack = tree.children.last().expect("toast_stack appended");
let exiting = &stack.children[0];
assert_eq!(exiting.opacity, 0.0);
assert_eq!(exiting.translate, (0.0, 16.0));
assert!(exiting.animate_timing().is_some());
let live = &stack.children[1];
assert_eq!(live.opacity, 1.0);
assert!(live.animate_timing().is_none());
let mut tree2 = crate::stack(std::iter::empty::<El>());
assign_ids(&mut tree2);
let after = later + TOAST_EXIT_WINDOW + Duration::from_millis(10);
assert!(synthesize_toasts(&mut tree2, &mut state, after));
assert_eq!(state.toast.queue.len(), 1, "exit window elapsed");
assert_eq!(state.toast.queue[0].message, "new");
}
#[test]
fn dismissed_toast_exits_through_the_window() {
let mut tree = crate::stack(std::iter::empty::<El>());
let mut state = UiState::new();
let t0 = Instant::now();
state.push_toast(ToastSpec::info("bye").with_ttl(Duration::from_secs(60)), t0);
let id = state.toast.queue[0].id;
assign_ids(&mut tree);
state.dismiss_toast(id);
assert_eq!(state.toast.queue.len(), 1, "dismiss marks, doesn't remove");
assert!(state.toast.queue[0].dismissed);
let t1 = t0 + Duration::from_millis(50);
assert!(synthesize_toasts(&mut tree, &mut state, t1));
assert_eq!(state.toast.queue[0].exit_started, Some(t1));
let mut tree2 = crate::stack(std::iter::empty::<El>());
assign_ids(&mut tree2);
let t2 = t1 + TOAST_EXIT_WINDOW + Duration::from_millis(10);
assert!(!synthesize_toasts(&mut tree2, &mut state, t2));
assert!(state.toast.queue.is_empty());
}
#[test]
fn toast_cards_are_keyed_by_id() {
let mut tree = crate::stack(std::iter::empty::<El>());
let mut state = UiState::new();
let now = Instant::now();
state.push_toast(ToastSpec::info("a"), now);
state.push_toast(ToastSpec::info("b"), now);
assign_ids(&mut tree);
synthesize_toasts(&mut tree, &mut state, now);
let stack = tree.children.last().expect("toast_stack appended");
let keys: Vec<_> = stack.children.iter().map(|c| c.key.as_deref()).collect();
let id0 = state.toast.queue[0].id;
let id1 = state.toast.queue[1].id;
assert_eq!(
keys,
vec![
Some(format!("toast-{id0}")).as_deref(),
Some(format!("toast-{id1}")).as_deref(),
]
);
}
#[test]
fn synthesize_returns_false_when_no_toasts() {
let mut tree = crate::stack(std::iter::empty::<El>());
let mut state = UiState::new();
let pending = synthesize_toasts(&mut tree, &mut state, Instant::now());
assert!(!pending);
assert!(tree.children.is_empty());
}
#[test]
fn only_newest_toasts_render_beyond_the_visible_cap() {
let mut tree = crate::stack(std::iter::empty::<El>());
let mut state = UiState::new();
let now = Instant::now();
for i in 0..MAX_VISIBLE_TOASTS + 2 {
state.push_toast(ToastSpec::info(format!("t{i}")), now);
}
assign_ids(&mut tree);
synthesize_toasts(&mut tree, &mut state, now);
assert_eq!(state.toast.queue.len(), MAX_VISIBLE_TOASTS + 2);
let stack = tree.children.last().expect("toast_stack appended");
assert_eq!(stack.children.len(), MAX_VISIBLE_TOASTS);
let first_card = &stack.children[0];
let body = &first_card.children[1];
assert_eq!(
body.text.as_deref(),
Some("t2"),
"oldest rendered card should be the first beyond the hidden ones"
);
}
#[test]
fn push_toast_drops_oldest_once_the_queue_is_full() {
let mut state = UiState::new();
let now = Instant::now();
for i in 0..MAX_QUEUED_TOASTS + 5 {
state.push_toast(
ToastSpec::info(format!("t{i}")).with_ttl(Duration::from_secs(600)),
now,
);
}
assert_eq!(state.toast.queue.len(), MAX_QUEUED_TOASTS);
assert_eq!(
state.toast.queue[0].message, "t5",
"oldest entries dropped first"
);
assert_eq!(
state.toast.queue.last().unwrap().message,
format!("t{}", MAX_QUEUED_TOASTS + 4),
);
}
#[test]
fn parse_dismiss_key_round_trip() {
assert_eq!(parse_dismiss_key("toast-dismiss-7"), Some(7));
assert_eq!(parse_dismiss_key("toast-dismiss-0"), Some(0));
assert_eq!(parse_dismiss_key("save"), None);
assert_eq!(parse_dismiss_key("toast-dismiss-abc"), None);
}
#[test]
fn toast_stack_layer_lays_out_at_root() {
let mut tree = crate::stack(std::iter::empty::<El>()).fill_size();
let mut state = UiState::new();
let now = Instant::now();
state.push_toast(ToastSpec::default("hello"), now);
synthesize_toasts(&mut tree, &mut state, now);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
let stack = tree.children.last().unwrap();
let r = stack.computed_rect;
assert!((r.w - 800.0).abs() < 0.01);
assert!((r.h - 600.0).abs() < 0.01);
}
}