use std::collections::VecDeque;
use iced::futures::channel::mpsc;
use iced::futures::{Stream, StreamExt, stream};
use iced::keyboard::key::Named;
use iced::time::{Duration, Instant};
use iced::widget::{self, column, container, mouse_area, row, stack, text};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Subscription, Theme,
Vector,
};
use crate::after_touch::after_touch;
use crate::icon::{Glyph, themed};
use crate::keys::{self, Chord, Keymap};
use crate::primitives::button::{self, Size};
use crate::primitives::icon_button::icon_button;
use crate::solid::solid;
use crate::theme::{Tokens, fade, mix, radius, space, text_size};
pub const CHANNEL_CAPACITY: usize = 32;
pub const BATCH_SIZE: usize = 16;
pub const DEFAULT_DURATION: Duration = Duration::from_secs(5);
pub const ACTION_DURATION: Duration = Duration::from_secs(10);
pub const DEFAULT_LIMIT: usize = 3;
pub const TICK: Duration = Duration::from_millis(250);
pub const MAX_WIDTH: f32 = 356.0;
pub const INSET: f32 = space::LG;
pub type Sender = mpsc::Sender<Toast>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Variant {
#[default]
Default,
Success,
Destructive,
}
impl Variant {
pub const ALL: [Variant; 3] = [Variant::Default, Variant::Success, Variant::Destructive];
fn glyph(self) -> Option<Glyph> {
match self {
Variant::Default => None,
Variant::Success => Some(crate::lucide!(CircleCheck)),
Variant::Destructive => Some(crate::lucide!(CircleX)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Position {
TopLeft,
TopCenter,
TopRight,
BottomLeft,
BottomCenter,
#[default]
BottomRight,
}
impl Position {
pub const ALL: [Position; 6] = [
Position::TopLeft,
Position::TopCenter,
Position::TopRight,
Position::BottomLeft,
Position::BottomCenter,
Position::BottomRight,
];
fn alignment(self) -> (Alignment, Alignment) {
match self {
Position::TopLeft => (Alignment::Start, Alignment::Start),
Position::TopCenter => (Alignment::Center, Alignment::Start),
Position::TopRight => (Alignment::End, Alignment::Start),
Position::BottomLeft => (Alignment::Start, Alignment::End),
Position::BottomCenter => (Alignment::Center, Alignment::End),
Position::BottomRight => (Alignment::End, Alignment::End),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Toast {
pub title: String,
pub description: Option<String>,
pub variant: Variant,
pub action: Option<String>,
pub duration: Option<Duration>,
}
pub fn toast(title: impl Into<String>) -> Toast {
Toast {
title: title.into(),
description: None,
variant: Variant::default(),
action: None,
duration: Some(DEFAULT_DURATION),
}
}
impl Toast {
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn variant(mut self, variant: Variant) -> Self {
self.variant = variant;
self
}
pub fn action(mut self, label: impl Into<String>) -> Self {
self.action = Some(label.into());
self
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
pub fn persistent(mut self) -> Self {
self.duration = None;
self
}
pub fn lifetime(&self) -> Option<Duration> {
let duration = self.duration?;
if self.action.is_some() {
return Some(duration.max(ACTION_DURATION));
}
Some(duration)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Id(u64);
#[derive(Debug, Clone)]
pub enum Event {
Ready(Sender),
Received(Vec<Toast>),
Dismiss(Id),
Action(Id),
Tick(Instant),
Pause,
Resume,
}
#[derive(Debug, Clone)]
pub enum Output<A = ()> {
Ready(Sender),
Action(Id, Toast),
Payload(A),
}
#[derive(Debug, Clone)]
struct Entry<A> {
id: Id,
toast: Toast,
payload: Option<A>,
expires_at: Option<Instant>,
}
#[derive(Debug, Clone)]
pub struct State<A = ()> {
entries: VecDeque<Entry<A>>,
next_id: u64,
limit: usize,
paused: bool,
last_tick: Option<Instant>,
}
impl<A> Default for State<A> {
fn default() -> Self {
Self {
entries: VecDeque::new(),
next_id: 0,
limit: DEFAULT_LIMIT,
paused: false,
last_tick: None,
}
}
}
impl State {
pub fn new() -> Self {
Self::default()
}
}
impl<A> State<A> {
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = limit.max(1);
self
}
pub fn limit(&self) -> usize {
self.limit
}
pub fn push(&mut self, toast: Toast) -> Id {
self.enqueue(toast, None)
}
pub fn push_with(&mut self, toast: Toast, payload: A) -> Id {
self.enqueue(toast, Some(payload))
}
fn enqueue(&mut self, toast: Toast, payload: Option<A>) -> Id {
let id = Id(self.next_id);
self.next_id += 1;
let entry = Entry {
id,
toast,
payload,
expires_at: None,
};
if entry.toast.action.is_some() {
self.insert_ahead(entry);
} else {
self.entries.push_back(entry);
}
id
}
fn insert_ahead(&mut self, entry: Entry<A>) {
let limit = self.limit;
if self.entries.len() < limit {
self.entries.push_back(entry);
return;
}
let newest_plain = (0..limit)
.rev()
.find(|&index| self.entries[index].toast.action.is_none());
let Some(index) = newest_plain else {
let after_actions = self
.entries
.iter()
.rposition(|entry| entry.toast.action.is_some())
.map_or(0, |index| index + 1);
self.entries.insert(after_actions, entry);
return;
};
let Some(mut displaced) = self.entries.remove(index) else {
return;
};
displaced.expires_at = None;
self.entries.insert(limit - 1, entry);
self.entries.insert(limit, displaced);
}
pub fn dismiss(&mut self, id: Id) -> Option<Toast> {
self.remove(id).map(|entry| entry.toast)
}
fn remove(&mut self, id: Id) -> Option<Entry<A>> {
let index = self.entries.iter().position(|entry| entry.id == id)?;
let entry = self.entries.remove(index);
self.settle();
entry
}
fn settle(&mut self) {
if self.entries.is_empty() {
self.paused = false;
}
}
pub fn tick(&mut self, now: Instant) -> Vec<Id> {
let last = self.last_tick.replace(now);
if self.paused
&& let Some(last) = last
{
let paused_for = now.saturating_duration_since(last);
for entry in &mut self.entries {
entry.expires_at = entry.expires_at.map(|at| at + paused_for);
}
}
let mut expired = Vec::new();
self.entries.retain(|entry| {
let alive = entry.expires_at.is_none_or(|at| at > now);
if !alive {
expired.push(entry.id);
}
alive
});
self.settle();
let limit = self.limit;
for entry in self.entries.iter_mut().take(limit) {
if entry.expires_at.is_none() {
entry.expires_at = entry.toast.lifetime().map(|duration| now + duration);
}
}
expired
}
pub fn is_paused(&self) -> bool {
self.paused
}
pub fn update(&mut self, event: Event) -> Option<Output<A>> {
match event {
Event::Ready(sender) => Some(Output::Ready(sender)),
Event::Received(batch) => {
for toast in batch {
let _ = self.push(toast);
}
None
}
Event::Dismiss(id) => {
let _ = self.dismiss(id);
None
}
Event::Action(id) => {
let entry = self.remove(id)?;
Some(match entry.payload {
Some(payload) => Output::Payload(payload),
None => Output::Action(id, entry.toast),
})
}
Event::Tick(now) => {
let _ = self.tick(now);
None
}
Event::Pause => {
self.paused = true;
None
}
Event::Resume => {
self.paused = false;
None
}
}
}
pub fn visible(&self) -> impl Iterator<Item = (Id, &Toast)> {
self.entries
.iter()
.take(self.limit)
.map(|entry| (entry.id, &entry.toast))
}
pub fn waiting(&self) -> usize {
self.entries.len().saturating_sub(self.limit)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn needs_tick(&self) -> bool {
self.visible().any(|(_, toast)| toast.lifetime().is_some())
}
pub fn ids(&self) -> impl Iterator<Item = Id> {
self.entries.iter().map(|entry| entry.id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
DismissLatest,
DismissAll,
ActivateLatest,
}
impl Action {
pub fn events<A>(self, state: &State<A>) -> Vec<Event> {
match self {
Action::DismissLatest => state
.visible()
.map(|(id, _)| id)
.max()
.map(Event::Dismiss)
.into_iter()
.collect(),
Action::DismissAll => state.ids().map(Event::Dismiss).collect(),
Action::ActivateLatest => state
.visible()
.filter(|(_, toast)| toast.action.is_some())
.map(|(id, _)| id)
.max()
.map(Event::Action)
.into_iter()
.collect(),
}
}
}
impl keys::Action for Action {
const ALL: &'static [Self] = &[
Action::DismissLatest,
Action::DismissAll,
Action::ActivateLatest,
];
fn defaults() -> Keymap<Self> {
default_keymap()
}
fn name(self) -> &'static str {
match self {
Action::DismissLatest => "DismissLatest",
Action::DismissAll => "DismissAll",
Action::ActivateLatest => "ActivateLatest",
}
}
fn description(self) -> &'static str {
match self {
Action::DismissLatest => "Closes the newest visible toast.",
Action::DismissAll => "Closes every toast, including those waiting their turn.",
Action::ActivateLatest => {
"Presses the action button, such as Undo, of the newest visible toast that has one."
}
}
}
}
pub fn default_keymap() -> Keymap<Action> {
Keymap::new()
.bind(Chord::named(Named::Escape), Action::DismissLatest)
.bind(Chord::named(Named::Escape).shift(), Action::DismissAll)
.bind(Chord::character('z').alt(), Action::ActivateLatest)
}
pub fn subscription() -> Subscription<Event> {
Subscription::run(stream)
}
pub fn stream() -> impl Stream<Item = Event> {
let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
}
pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
receiver.ready_chunks(BATCH_SIZE)
}
pub fn timer<A>(state: &State<A>) -> Subscription<Event> {
if !state.needs_tick() {
return Subscription::none();
}
every(TICK).map(|_| Event::Tick(Instant::now()))
}
#[cfg(any(target_arch = "wasm32", feature = "tokio"))]
fn every(period: Duration) -> Subscription<()> {
iced::time::every(period).map(|_| ())
}
#[cfg(not(any(target_arch = "wasm32", feature = "tokio")))]
fn every(period: Duration) -> Subscription<()> {
Subscription::run_with(period, |period| {
let period = *period;
let (mut sender, receiver) = mpsc::channel(1);
std::thread::spawn(move || {
loop {
std::thread::sleep(period);
if sender
.try_send(())
.is_err_and(|error| error.is_disconnected())
{
break;
}
}
});
receiver
})
}
pub fn close_button_id(id: Id) -> widget::Id {
widget::Id::from(format!("iced-cube-toast-close-{}", id.0))
}
pub struct Toasts<'a, Message, A = ()> {
state: &'a State<A>,
content: Element<'a, Message>,
position: Position,
inset: Padding,
max_width: f32,
on_event: Option<Box<dyn Fn(Event) -> Message + 'a>>,
}
pub fn toasts<'a, Message, A>(
state: &'a State<A>,
content: impl Into<Element<'a, Message>>,
) -> Toasts<'a, Message, A> {
Toasts {
state,
content: content.into(),
position: Position::default(),
inset: Padding::from(INSET),
max_width: MAX_WIDTH,
on_event: None,
}
}
impl<Message, A: std::fmt::Debug> std::fmt::Debug for Toasts<'_, Message, A> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Toasts")
.field("state", self.state)
.field("position", &self.position)
.field("inset", &self.inset)
.field("max_width", &self.max_width)
.finish_non_exhaustive()
}
}
impl<'a, Message, A> Toasts<'a, Message, A> {
pub fn position(mut self, position: Position) -> Self {
self.position = position;
self
}
pub fn inset(mut self, inset: impl Into<Padding>) -> Self {
self.inset = inset.into();
self
}
pub fn max_width(mut self, max_width: f32) -> Self {
self.max_width = max_width.max(0.0);
self
}
pub fn on_event(mut self, on_event: impl Fn(Event) -> Message + 'a) -> Self {
self.on_event = Some(Box::new(on_event));
self
}
}
impl<'a, Message: Clone + 'a, A> From<Toasts<'a, Message, A>> for Element<'a, Message> {
fn from(toasts: Toasts<'a, Message, A>) -> Self {
let Toasts {
state,
content,
position,
inset,
max_width,
on_event,
} = toasts;
let emit = |event: Event| on_event.as_ref().map(|on_event| on_event(event));
let cards = state.visible().map(|(id, toast)| card(id, toast, &emit));
let cards = column(cards)
.spacing(space::SM)
.width(Length::Fill)
.max_width(max_width);
let cards: Element<'a, Message> = match (emit(Event::Pause), emit(Event::Resume)) {
(Some(pause), Some(resume)) => {
after_touch(mouse_area(cards).on_enter(pause).on_exit(resume))
}
_ => cards.into(),
};
let (align_x, align_y) = position.alignment();
let layer = container(cards)
.padding(inset)
.width(Length::Fill)
.height(Length::Fill)
.align_x(align_x)
.align_y(align_y);
stack![content, layer]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
fn card<'a, Message: Clone + 'a>(
id: Id,
toast: &'a Toast,
emit: &impl Fn(Event) -> Option<Message>,
) -> Element<'a, Message> {
let variant = toast.variant;
let mut body = column![
text(&toast.title)
.size(text_size::SM)
.font(crate::theme::semibold())
]
.spacing(2)
.width(Length::Fill);
if let Some(description) = &toast.description {
body = body.push(
text(description)
.size(text_size::SM)
.style(move |theme: &Theme| text::Style {
color: Some(Tokens::of(theme).muted_foreground),
}),
);
}
let mut content = row![].spacing(space::MD).align_y(Alignment::Center);
if let Some(glyph) = variant.glyph() {
content = content.push(themed(glyph, 18.0, 1.0, move |theme| {
accent(&Tokens::of(theme), variant)
}));
}
content = content.push(body);
if let Some(label) = &toast.action {
content = content.push(
button::button(label.as_str())
.variant(button::Variant::Outline)
.size(Size::Sm)
.on_press_maybe(emit(Event::Action(id))),
);
}
content = content.push(
container(
icon_button(crate::lucide!(X))
.size(Size::Sm)
.on_press_maybe(emit(Event::Dismiss(id))),
)
.id(close_button_id(id)),
);
solid(
container(content)
.padding([space::MD, space::LG])
.width(Length::Fill)
.style(move |theme| style(&Tokens::of(theme), variant)),
)
}
pub fn accent(tokens: &Tokens, variant: Variant) -> Color {
match variant {
Variant::Default => tokens.foreground,
Variant::Success => tokens.success,
Variant::Destructive => tokens.destructive,
}
}
pub fn style(tokens: &Tokens, variant: Variant) -> container::Style {
let (background, border) = match variant {
Variant::Default => (tokens.background, tokens.border),
Variant::Success | Variant::Destructive => {
let accent = accent(tokens, variant);
(
mix(tokens.background, accent, 0.08),
mix(tokens.border, accent, 0.45),
)
}
};
let shadow_alpha = if tokens.is_dark { 0.5 } else { 0.12 };
container::Style {
background: Some(Background::Color(background)),
text_color: Some(tokens.foreground),
border: Border {
color: border,
width: 1.0,
radius: radius::LG.into(),
},
shadow: Shadow {
color: fade(Color::BLACK, shadow_alpha),
offset: Vector::new(0.0, 4.0),
blur_radius: 12.0,
},
snap: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{dark, light};
use futures::SinkExt;
use futures::executor::block_on;
fn titles(state: &State) -> Vec<&str> {
state
.visible()
.map(|(_, toast)| toast.title.as_str())
.collect()
}
#[test]
fn builder_defaults() {
let t = toast("Saved");
assert_eq!(t.title, "Saved");
assert_eq!(t.variant, Variant::Default);
assert_eq!(t.duration, Some(DEFAULT_DURATION));
assert!(t.description.is_none());
assert!(t.action.is_none());
assert!(toast("Sticky").persistent().duration.is_none());
}
#[test]
fn layer_defaults_keep_the_corner_and_options_override_them() {
let state: State = State::new();
let layer: Toasts<'_, (), ()> = toasts(&state, text("Content"));
assert_eq!(layer.position, Position::BottomRight);
assert_eq!(layer.inset, Padding::from(INSET));
assert_eq!(layer.max_width, MAX_WIDTH);
let layer: Toasts<'_, (), ()> = toasts(&state, text("Content"))
.inset(Padding::from(16).bottom(96))
.max_width(-1.0);
assert_eq!(layer.inset.bottom, 96.0);
assert_eq!(layer.max_width, 0.0);
}
#[test]
fn queue_keeps_arrival_order_and_unique_ids() {
let mut state = State::new();
let a = state.push(toast("a"));
let b = state.push(toast("b"));
let _ = state.update(Event::Received(vec![toast("c"), toast("d")]));
assert_ne!(a, b);
assert_eq!(state.len(), 4);
assert_eq!(titles(&state), ["a", "b", "c"]);
assert_eq!(state.waiting(), 1);
}
#[test]
fn limit_caps_visible_toasts_and_is_at_least_one() {
let mut state = State::new().with_limit(2);
for title in ["a", "b", "c"] {
let _ = state.push(toast(title));
}
assert_eq!(titles(&state), ["a", "b"]);
assert_eq!(State::new().with_limit(0).limit(), 1);
}
#[test]
fn toasts_expire_using_injected_time() {
let start = Instant::now();
let mut state = State::new();
let id = state.push(toast("a").duration(Duration::from_secs(2)));
assert!(state.tick(start).is_empty());
assert!(state.tick(start + Duration::from_millis(1999)).is_empty());
assert_eq!(state.tick(start + Duration::from_secs(2)), vec![id]);
assert!(state.is_empty());
}
#[test]
fn waiting_toasts_start_counting_when_shown() {
let start = Instant::now();
let mut state = State::new().with_limit(1);
let _ = state.push(toast("a").duration(Duration::from_secs(1)));
let _ = state.push(toast("b").duration(Duration::from_secs(1)));
let _ = state.tick(start);
let _ = state.tick(start + Duration::from_secs(1));
assert_eq!(titles(&state), ["b"]);
assert!(state.tick(start + Duration::from_millis(1500)).is_empty());
assert_eq!(state.tick(start + Duration::from_secs(2)).len(), 1);
}
#[test]
fn persistent_toasts_never_expire_and_need_no_timer() {
let start = Instant::now();
let mut state = State::new();
let _ = state.push(toast("a").persistent());
assert!(!state.needs_tick());
let _ = state.tick(start + Duration::from_secs(3600));
assert_eq!(state.len(), 1);
}
#[test]
fn empty_state_needs_no_timer() {
let mut state = State::new();
assert!(!state.needs_tick());
let _ = state.push(toast("a"));
assert!(state.needs_tick());
}
#[test]
fn dismiss_and_action_remove_the_toast() {
let mut state = State::new();
let a = state.push(toast("a"));
let b = state.push(toast("b").action("Undo"));
assert!(state.update(Event::Dismiss(a)).is_none());
let Some(Output::Action(id, toast)) = state.update(Event::Action(b)) else {
panic!("expected an action output");
};
assert_eq!(id, b);
assert_eq!(toast.action.as_deref(), Some("Undo"));
assert!(state.is_empty());
assert!(state.update(Event::Action(b)).is_none());
}
#[derive(Debug, Clone, PartialEq)]
enum Undo {
Restore(&'static str),
}
#[test]
fn an_action_hands_back_the_payload_it_was_queued_with() {
let mut state: State<Undo> = State::default();
let plain = state.push(toast("Saved").action("View"));
let undo = state.push_with(toast("Archived").action("Undo"), Undo::Restore("inbox"));
assert_eq!(state.len(), 2);
let Some(Output::Payload(payload)) = state.update(Event::Action(undo)) else {
panic!("expected the payload");
};
assert_eq!(payload, Undo::Restore("inbox"));
assert!(matches!(
state.update(Event::Action(plain)),
Some(Output::Action(id, _)) if id == plain
));
assert!(state.is_empty());
}
#[test]
fn dismissing_drops_the_payload() {
let mut state: State<Undo> = State::default();
let undo = state.push_with(toast("Archived").action("Undo"), Undo::Restore("inbox"));
assert!(state.update(Event::Dismiss(undo)).is_none());
assert!(state.update(Event::Action(undo)).is_none());
let expired = state.push_with(
toast("Archived").duration(Duration::from_secs(1)),
Undo::Restore("inbox"),
);
let start = Instant::now();
let _ = state.tick(start);
assert_eq!(state.tick(start + Duration::from_secs(1)), vec![expired]);
assert!(state.update(Event::Action(expired)).is_none());
}
fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
let chord: Chord = chord.parse().unwrap();
keymap.resolve(chord.key(), chord.modifiers())
}
#[test]
fn default_keymap_and_overrides() {
let keymap = default_keymap();
assert_eq!(press(&keymap, "Escape"), Some(Action::DismissLatest));
assert_eq!(press(&keymap, "Shift+Escape"), Some(Action::DismissAll));
assert_eq!(press(&keymap, "Alt+Z"), Some(Action::ActivateLatest));
assert_eq!(press(&keymap, "Ctrl+Escape"), None);
assert_eq!(press(&keymap, "Z"), None);
for action in <Action as keys::Action>::ALL {
assert!(!keymap.chords(action).is_empty(), "{action:?}");
assert!(keys::Action::description(*action).ends_with('.'));
}
let custom = keymap
.unbind(&"Shift+Escape".parse().unwrap())
.bind("Ctrl+Shift+X".parse().unwrap(), Action::DismissAll);
assert_eq!(press(&custom, "Shift+Escape"), None);
assert_eq!(press(&custom, "ctrl+shift+x"), Some(Action::DismissAll));
}
#[test]
fn dismiss_latest_closes_the_newest_visible_toast() {
let mut state = State::new().with_limit(2);
let _ = state.push(toast("a"));
let b = state.push(toast("b"));
let _ = state.push(toast("c"));
let events = Action::DismissLatest.events(&state);
assert!(matches!(events.as_slice(), [Event::Dismiss(id)] if *id == b));
for event in events {
let _ = state.update(event);
}
assert_eq!(titles(&state), ["a", "c"]);
assert!(Action::DismissLatest.events(&State::new()).is_empty());
}
#[test]
fn action_toasts_last_at_least_the_action_duration() {
assert_eq!(toast("a").action("Undo").lifetime(), Some(ACTION_DURATION));
let long = Duration::from_secs(30);
assert_eq!(
toast("a").action("Undo").duration(long).lifetime(),
Some(long)
);
assert_eq!(toast("a").action("Undo").persistent().lifetime(), None);
assert_eq!(toast("a").lifetime(), Some(DEFAULT_DURATION));
let start = Instant::now();
let mut state = State::new();
let _ = state.push(toast("Deleted").action("Undo"));
let _ = state.tick(start);
assert!(state.tick(start + DEFAULT_DURATION).is_empty());
assert_eq!(state.tick(start + ACTION_DURATION).len(), 1);
}
#[test]
fn an_action_toast_takes_the_place_of_the_newest_plain_one() {
let start = Instant::now();
let mut state = State::new();
for title in ["a", "b", "c", "d"] {
let _ = state.push(toast(title));
}
let _ = state.tick(start);
let _ = state.push(toast("Undo me").action("Undo"));
assert_eq!(titles(&state), ["a", "b", "Undo me"]);
assert_eq!(state.waiting(), 2);
let _ = state.tick(start + Duration::from_secs(1));
let _ = state.tick(start + DEFAULT_DURATION);
assert_eq!(titles(&state), ["Undo me", "c", "d"]);
assert!(state.tick(start + Duration::from_secs(9)).is_empty());
assert_eq!(state.tick(start + DEFAULT_DURATION * 2).len(), 2);
}
#[test]
fn action_toasts_wait_ahead_of_plain_ones_when_every_visible_toast_has_an_action() {
let mut state = State::new().with_limit(2);
let _ = state.push(toast("plain"));
let _ = state.push(toast("one").action("Undo"));
let _ = state.push(toast("two").action("Undo"));
assert_eq!(titles(&state), ["one", "two"]);
let _ = state.push(toast("three").action("Undo"));
let order: Vec<_> = state
.entries
.iter()
.map(|entry| entry.toast.title.as_str())
.collect();
assert_eq!(order, ["one", "two", "three", "plain"]);
}
#[test]
fn a_plain_toast_with_room_just_joins_the_end() {
let mut state = State::new();
let _ = state.push(toast("one").action("Undo"));
let _ = state.push(toast("plain"));
let _ = state.push(toast("two").action("Undo"));
assert_eq!(titles(&state), ["one", "plain", "two"]);
}
#[test]
fn hovering_pauses_every_countdown() {
let start = Instant::now();
let mut state = State::new();
let id = state.push(toast("a").duration(Duration::from_secs(2)));
let _ = state.tick(start);
let _ = state.update(Event::Pause);
assert!(state.is_paused());
assert!(state.needs_tick());
assert!(state.tick(start + Duration::from_secs(1)).is_empty());
assert!(state.tick(start + Duration::from_secs(10)).is_empty());
let _ = state.update(Event::Resume);
assert!(!state.is_paused());
let resumed = start + Duration::from_secs(10);
assert!(state.tick(resumed + Duration::from_millis(1900)).is_empty());
assert_eq!(state.tick(resumed + Duration::from_secs(2)), vec![id]);
}
#[test]
fn the_pause_ends_when_the_last_toast_closes() {
let mut state = State::new();
let id = state.push(toast("a"));
let _ = state.update(Event::Pause);
let _ = state.dismiss(id);
assert!(!state.is_paused());
}
#[test]
fn activate_latest_presses_the_newest_visible_action() {
let mut state: State<Undo> = State::default();
assert!(Action::ActivateLatest.events(&state).is_empty());
let _ = state.push(toast("plain"));
assert!(Action::ActivateLatest.events(&state).is_empty());
let _ = state.push_with(toast("first").action("Undo"), Undo::Restore("a"));
let second = state.push_with(toast("second").action("Undo"), Undo::Restore("b"));
let _ = state.push(toast("newer plain"));
let events = Action::ActivateLatest.events(&state);
assert!(matches!(events.as_slice(), [Event::Action(id)] if *id == second));
let outputs: Vec<_> = events
.into_iter()
.filter_map(|event| state.update(event))
.collect();
assert!(matches!(
outputs.as_slice(),
[Output::Payload(Undo::Restore("b"))]
));
}
#[test]
fn dismiss_all_includes_waiting_toasts() {
let mut state = State::new().with_limit(1);
for title in ["a", "b", "c"] {
let _ = state.push(toast(title));
}
let events = Action::DismissAll.events(&state);
assert_eq!(events.len(), 3);
for event in events {
let _ = state.update(event);
}
assert!(state.is_empty());
assert!(Action::DismissAll.events(&state).is_empty());
}
#[test]
fn ready_hands_the_sender_back() {
let (sender, _receiver) = mpsc::channel(1);
let output = State::new().update(Event::Ready(sender));
assert!(matches!(output, Some(Output::Ready(_))));
}
#[test]
fn stream_emits_ready_first_then_batches() {
let mut events = Box::pin(stream());
let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
panic!("first event must be Ready");
};
block_on(async {
for title in ["a", "b", "c"] {
sender.send(toast(title)).await.ok();
}
});
let Some(Event::Received(batch)) = block_on(events.next()) else {
panic!("expected a batch");
};
let titles: Vec<_> = batch.iter().map(|toast| toast.title.as_str()).collect();
assert_eq!(titles, ["a", "b", "c"]);
}
#[test]
fn batches_split_bursts_at_batch_size() {
let items = stream::iter(0..BATCH_SIZE + 3);
let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
assert_eq!(sizes, [BATCH_SIZE, 3]);
}
#[test]
fn default_style_uses_background_and_border_tokens() {
for theme in [light(), dark()] {
let tokens = Tokens::of(&theme);
let style = style(&tokens, Variant::Default);
assert_eq!(style.background, Some(Background::Color(tokens.background)));
assert_eq!(style.border.color, tokens.border);
assert_eq!(style.text_color, Some(tokens.foreground));
}
}
#[test]
fn variants_are_tinted_differently_in_both_themes() {
for theme in [light(), dark()] {
let tokens = Tokens::of(&theme);
let styles = Variant::ALL.map(|variant| style(&tokens, variant).border.color);
assert_ne!(styles[0], styles[1]);
assert_ne!(styles[1], styles[2]);
assert_eq!(accent(&tokens, Variant::Destructive), tokens.destructive);
assert_eq!(accent(&tokens, Variant::Success), tokens.success);
}
}
#[test]
fn dark_shadow_is_stronger() {
let light = style(&Tokens::of(&light()), Variant::Default)
.shadow
.color
.a;
let dark = style(&Tokens::of(&dark()), Variant::Default).shadow.color.a;
assert!(dark > light);
}
}