use std::time::Duration;
use crate::core::{Align, Anchor, AppHandle, Axis, BoxStyle, Cx, Element, OverlaySpec, Role};
use crate::geometry::Insets;
use crate::style::Theme;
const MAX_SHOWN: usize = 4;
const WIDTH: f64 = 320.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ToastId(u64);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToastKind {
#[default]
Info,
Success,
Warning,
Error,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Toast {
pub id: ToastId,
pub message: String,
pub kind: ToastKind,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Toasts {
items: Vec<Toast>,
next: u64,
}
impl Toasts {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, message: impl Into<String>, kind: ToastKind) -> ToastId {
let id = ToastId(self.next);
self.next += 1;
self.items.push(Toast {
id,
message: message.into(),
kind,
});
id
}
pub fn push_for<S>(
&mut self,
handle: &AppHandle<S>,
duration: Duration,
field: impl Fn(&mut S) -> &mut Toasts + Send + 'static,
message: impl Into<String>,
kind: ToastKind,
) -> ToastId {
let id = self.push(message, kind);
handle.after(duration, move |s| {
field(s).dismiss(id);
});
id
}
pub fn dismiss(&mut self, id: ToastId) -> bool {
let before = self.items.len();
self.items.retain(|t| t.id != id);
self.items.len() != before
}
pub fn clear(&mut self) {
self.items.clear();
}
pub fn items(&self) -> &[Toast] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
pub fn toasts<S>(
cx: &mut Cx<S>,
theme: &Theme,
state: &Toasts,
field: impl Fn(&mut S) -> &mut Toasts + Copy + 'static,
) {
if state.is_empty() {
return;
}
let shown = &state.items[state.items.len().saturating_sub(MAX_SHOWN)..];
let cards = shown
.iter()
.map(|t| toast_card(cx, theme, t, field))
.collect();
let content = Element::stack(Axis::Vertical, cards)
.gap(theme.spacing.sm)
.align(Align::Start, Align::Stretch)
.width(WIDTH);
cx.overlay(OverlaySpec {
content,
anchor: Anchor::Aligned {
horizontal: Align::End,
vertical: Align::End,
margin: theme.spacing.lg,
},
modal: false,
dismiss: None,
});
}
fn toast_card<S>(
cx: &mut Cx<S>,
theme: &Theme,
toast: &Toast,
field: impl Fn(&mut S) -> &mut Toasts + Copy + 'static,
) -> Element {
let p = &theme.palette;
let accent = match toast.kind {
ToastKind::Info => p.primary,
ToastKind::Success => p.success,
ToastKind::Warning => p.warning,
ToastKind::Error => p.danger,
};
let bar = Element::boxed(BoxStyle {
fill: Some(accent),
radius: 2.0,
border: None,
})
.width(4.0);
let message = Element::text(toast.message.clone(), theme.typography.body, p.text)
.wrap()
.grow(1.0)
.shrink(1.0);
let id = toast.id;
let close = Element::stack(
Axis::Horizontal,
vec![Element::text(
"\u{00D7}",
theme.typography.title,
p.text_muted,
)],
)
.radius(theme.radius / 2.0)
.align(Align::Center, Align::Center)
.min_width(24.0)
.min_height(24.0)
.a11y_label("Dismiss")
.on_tap(cx, move |s: &mut S| {
field(s).dismiss(id);
});
let message = Element::stack(Axis::Horizontal, vec![message])
.align(Align::Start, Align::Center)
.grow(1.0)
.shrink(1.0);
Element::stack(Axis::Horizontal, vec![bar, message, close])
.fill(p.surface)
.border(p.border, 1.0)
.radius(theme.radius)
.padding(Insets::uniform(theme.spacing.md))
.gap(theme.spacing.md)
.align(Align::Start, Align::Stretch)
.role(Role::Alert)
.a11y_label(toast.message.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::App;
use crate::geometry::Size;
use crate::platform::time::Instant;
#[test]
fn push_and_dismiss() {
let mut t = Toasts::new();
let a = t.push("one", ToastKind::Info);
let b = t.push("two", ToastKind::Error);
assert_ne!(a, b);
assert!(t.dismiss(a));
assert!(!t.dismiss(a), "already gone");
assert_eq!(t.items()[0].message, "two");
t.clear();
assert!(t.is_empty());
}
#[test]
fn push_for_dismisses_on_its_timer() {
struct State {
toasts: Toasts,
}
let handle = AppHandle::<State>::new();
let mut state = State {
toasts: Toasts::new(),
};
let start = Instant::now();
state.toasts.push_for(
&handle,
Duration::from_millis(50),
|s: &mut State| &mut s.toasts,
"brief",
ToastKind::Info,
);
state.toasts.push("sticky", ToastKind::Info);
handle.pump(&mut state, start);
assert_eq!(state.toasts.len(), 2, "not due yet");
handle.pump(&mut state, start + Duration::from_millis(60));
assert_eq!(state.toasts.len(), 1);
assert_eq!(state.toasts.items()[0].message, "sticky");
}
#[derive(Default)]
struct App1 {
toasts: Toasts,
clicks: u32,
}
#[test]
fn toasts_float_bottom_right_and_dismiss_on_click() {
let mut state = App1::default();
for i in 0..6 {
state.toasts.push(format!("note {i}"), ToastKind::Success);
}
let mut app = App::new(state, |s: &App1, cx: &mut Cx<App1>| {
let theme = *cx.theme();
toasts(cx, &theme, &s.toasts, |s: &mut App1| &mut s.toasts);
Element::stack(Axis::Vertical, vec![])
.grow(1.0)
.on_tap(cx, |s: &mut App1| s.clicks += 1)
})
.logical_size(Size::new(600.0, 500.0));
app.render_once();
let tree = app.accessibility_tree().unwrap();
let alerts: Vec<_> = tree
.descendants()
.into_iter()
.filter(|n| n.role == Role::Alert)
.cloned()
.collect();
assert_eq!(alerts.len(), MAX_SHOWN, "only the newest few show");
assert_eq!(alerts[0].name, "note 2");
let last = alerts.last().unwrap().bounds;
assert!(last.max_x() > 560.0 && last.max_y() > 460.0, "{last:?}");
assert!(app.click_at(crate::geometry::Point::new(20.0, 20.0)));
assert_eq!(app.state().clicks, 1);
let close = alerts
.last()
.unwrap()
.children
.iter()
.find(|c| c.name == "Dismiss")
.unwrap()
.bounds
.center();
assert!(app.click_at(close));
assert_eq!(app.state().toasts.len(), 5);
assert!(
app.state()
.toasts
.items()
.iter()
.all(|t| t.message != "note 5")
);
}
}