use std::borrow::Cow;
use std::time::Duration;
const DEFAULT_DURATION: Duration = Duration::from_secs(4);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ToastKind {
#[default]
Default,
Success,
Error,
Warning,
Info,
Loading,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toast<'a> {
id: Option<Cow<'a, str>>,
title: Cow<'a, str>,
description: Option<Cow<'a, str>>,
kind: ToastKind,
duration: Option<Duration>,
border: bool,
}
impl<'a> Toast<'a> {
#[must_use]
pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
Self {
id: None,
title: title.into(),
description: None,
kind: ToastKind::Default,
duration: Some(DEFAULT_DURATION),
border: true,
}
}
#[must_use]
pub fn success(title: impl Into<Cow<'a, str>>) -> Self {
Self::new(title).kind(ToastKind::Success)
}
#[must_use]
pub fn error(title: impl Into<Cow<'a, str>>) -> Self {
Self::new(title).kind(ToastKind::Error)
}
#[must_use]
pub fn warning(title: impl Into<Cow<'a, str>>) -> Self {
Self::new(title).kind(ToastKind::Warning)
}
#[must_use]
pub fn info(title: impl Into<Cow<'a, str>>) -> Self {
Self::new(title).kind(ToastKind::Info)
}
#[must_use]
pub fn loading(title: impl Into<Cow<'a, str>>) -> Self {
Self::new(title).kind(ToastKind::Loading)
}
#[must_use]
pub fn description(mut self, description: impl Into<Cow<'a, str>>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub const fn kind(mut self, kind: ToastKind) -> Self {
self.kind = kind;
self
}
#[must_use]
pub const fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
#[must_use]
pub const fn persistent(mut self) -> Self {
self.duration = None;
self
}
#[must_use]
pub const fn border(mut self, border: bool) -> Self {
self.border = border;
self
}
#[must_use]
pub fn id(mut self, id: impl Into<Cow<'a, str>>) -> Self {
self.id = Some(id.into());
self
}
#[must_use]
pub fn is_expired_after(&self, age: Duration) -> bool {
match self.duration {
Some(duration) => age >= duration,
None => false,
}
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
#[must_use]
pub fn description_text(&self) -> Option<&str> {
self.description.as_deref()
}
#[must_use]
pub const fn toast_kind(&self) -> ToastKind {
self.kind
}
#[must_use]
pub const fn is_bordered(&self) -> bool {
self.border
}
#[must_use]
pub fn toast_id(&self) -> Option<&str> {
self.id.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToastEntry<'a> {
toast: Toast<'a>,
created_at: Duration,
}
impl<'a> ToastEntry<'a> {
#[must_use]
pub const fn new(toast: Toast<'a>, created_at: Duration) -> Self {
Self { toast, created_at }
}
#[must_use]
pub const fn toast(&self) -> &Toast<'a> {
&self.toast
}
#[must_use]
pub const fn created_at(&self) -> Duration {
self.created_at
}
#[must_use]
pub fn age(&self, now: Duration) -> Duration {
now.saturating_sub(self.created_at)
}
#[must_use]
pub fn is_expired(&self, now: Duration) -> bool {
self.toast.is_expired_after(self.age(now))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToasterState<'a> {
toasts: Vec<ToastEntry<'a>>,
}
impl<'a> ToasterState<'a> {
#[must_use]
pub const fn new() -> Self {
Self { toasts: Vec::new() }
}
pub fn push(&mut self, toast: Toast<'a>, created_at: Duration) {
self.toasts.push(ToastEntry::new(toast, created_at));
}
#[must_use]
pub fn prune_expired(&mut self, now: Duration) -> bool {
let previous_len = self.toasts.len();
self.toasts.retain(|toast| !toast.is_expired(now));
self.toasts.len() != previous_len
}
#[must_use]
pub fn time_until_next_expiry(&self, now: Duration) -> Option<Duration> {
self.toasts
.iter()
.filter_map(|entry| {
entry
.toast
.duration
.map(|duration| duration.saturating_sub(entry.age(now)))
})
.min()
}
#[must_use = "redraw when a toast was dismissed"]
pub fn dismiss(&mut self, id: &str) -> bool {
match self.position_of(id) {
Some(index) => {
self.toasts.remove(index);
true
}
None => false,
}
}
#[must_use = "redraw when a toast was replaced"]
pub fn replace(&mut self, id: &str, toast: Toast<'a>, now: Duration) -> bool {
match self.position_of(id) {
Some(index) => {
self.toasts[index] = ToastEntry::new(toast, now);
true
}
None => false,
}
}
fn position_of(&self, id: &str) -> Option<usize> {
self.toasts
.iter()
.position(|entry| entry.toast.toast_id() == Some(id))
}
#[must_use]
pub fn pop_newest(&mut self) -> Option<Toast<'a>> {
self.toasts.pop().map(|entry| entry.toast)
}
pub fn clear(&mut self) {
self.toasts.clear();
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.toasts.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.toasts.len()
}
#[must_use]
pub fn entries(&self) -> &[ToastEntry<'a>] {
&self.toasts
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persistent_toasts_do_not_expire() {
let toast = Toast::new("saved").persistent();
assert!(!toast.is_expired_after(Duration::from_secs(60)));
}
#[test]
fn zero_duration_toast_expires_immediately() {
let toast = Toast::new("done").duration(Duration::ZERO);
assert!(toast.is_expired_after(Duration::ZERO));
}
#[test]
fn toaster_state_prunes_expired_toasts() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("old"), Duration::ZERO);
toasts.push(Toast::new("new"), Duration::from_millis(10));
assert!(toasts.prune_expired(DEFAULT_DURATION));
assert_eq!(toasts.len(), 1);
assert_eq!(toasts.entries()[0].toast().title(), "new");
assert!(!toasts.prune_expired(DEFAULT_DURATION));
}
#[test]
fn toaster_state_schedules_the_earliest_expiring_toast() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("persistent").persistent(), Duration::ZERO);
toasts.push(
Toast::new("later").duration(Duration::from_secs(10)),
Duration::from_secs(2),
);
toasts.push(
Toast::new("next").duration(Duration::from_secs(4)),
Duration::from_secs(3),
);
assert_eq!(
toasts.time_until_next_expiry(Duration::from_secs(5)),
Some(Duration::from_secs(2))
);
}
#[test]
fn toaster_state_schedules_immediate_cleanup_for_expired_toasts() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("expired"), Duration::ZERO);
assert_eq!(
toasts.time_until_next_expiry(DEFAULT_DURATION),
Some(Duration::ZERO)
);
}
#[test]
fn dismiss_removes_the_oldest_match_by_id() {
let mut toasts = ToasterState::new();
toasts.push(
Toast::loading("saving A").persistent().id("save"),
Duration::ZERO,
);
toasts.push(
Toast::loading("saving B").persistent().id("save"),
Duration::ZERO,
);
toasts.push(Toast::new("other"), Duration::ZERO);
assert!(toasts.dismiss("save"));
assert_eq!(toasts.len(), 2);
assert_eq!(toasts.entries()[0].toast().title(), "saving B");
assert!(toasts.dismiss("save"));
assert!(!toasts.dismiss("save"), "no toast carries the id anymore");
assert_eq!(toasts.entries()[0].toast().title(), "other");
}
#[test]
fn dismiss_with_an_unknown_id_returns_false_and_changes_nothing() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("saved").id("save"), Duration::ZERO);
assert!(!toasts.dismiss("missing"));
assert_eq!(toasts.len(), 1);
}
#[test]
fn replace_swaps_in_place_and_restarts_the_duration() {
let mut toasts = ToasterState::new();
toasts.push(
Toast::loading("saving").persistent().id("save"),
Duration::ZERO,
);
toasts.push(Toast::new("other").persistent(), Duration::from_secs(1));
assert!(toasts.replace(
"save",
Toast::success("saved").id("save"),
Duration::from_secs(10),
));
let entry = &toasts.entries()[0];
assert_eq!(entry.toast().title(), "saved");
assert_eq!(entry.created_at(), Duration::from_secs(10));
let just_before = (Duration::from_secs(10) + DEFAULT_DURATION)
.checked_sub(Duration::from_millis(1))
.expect("the deadline is past one millisecond");
assert!(!entry.is_expired(just_before));
assert!(entry.is_expired(Duration::from_secs(10) + DEFAULT_DURATION));
assert_eq!(
toasts.time_until_next_expiry(Duration::from_secs(11)),
Some(Duration::from_secs(3))
);
}
#[test]
fn replace_with_an_unknown_id_returns_false_and_changes_nothing() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("saved").id("save"), Duration::ZERO);
assert!(!toasts.replace("missing", Toast::new("nope"), Duration::ZERO));
assert_eq!(toasts.entries()[0].toast().title(), "saved");
}
#[test]
fn toaster_state_does_not_schedule_persistent_toasts() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("persistent").persistent(), Duration::ZERO);
assert_eq!(toasts.time_until_next_expiry(Duration::from_secs(60)), None);
}
#[test]
fn pop_newest_removes_identified_or_anonymous_toasts() {
let mut toasts = ToasterState::new();
toasts.push(Toast::new("anonymous"), Duration::ZERO);
toasts.push(Toast::new("identified").id("latest"), Duration::ZERO);
assert_eq!(
toasts.pop_newest().as_ref().map(Toast::title),
Some("identified")
);
assert_eq!(
toasts.pop_newest().as_ref().map(Toast::title),
Some("anonymous")
);
assert_eq!(toasts.pop_newest(), None);
}
}