use std::collections::VecDeque;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Info,
#[allow(dead_code)]
Warn,
Error,
Fatal,
}
pub struct Toast {
text: String,
level: Level,
shown_at: Instant,
}
impl Toast {
pub fn info(s: impl Into<String>) -> Self {
Self::new(s, Level::Info)
}
#[allow(dead_code)]
pub fn warn(s: impl Into<String>) -> Self {
Self::new(s, Level::Warn)
}
pub fn error(s: impl Into<String>) -> Self {
Self::new(s, Level::Error)
}
#[allow(dead_code)]
pub fn fatal(s: impl Into<String>) -> Self {
Self::new(s, Level::Fatal)
}
fn new(s: impl Into<String>, level: Level) -> Self {
Self {
text: s.into(),
level,
shown_at: Instant::now(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn level(&self) -> Level {
self.level
}
pub fn shown_at(&self) -> Instant {
self.shown_at
}
fn reset_shown_at(&mut self) {
self.shown_at = Instant::now();
}
}
const MAX_ACTIVE: usize = 3;
const MAX_PENDING: usize = 32;
pub(crate) const TTL: Duration = Duration::from_secs(3);
const FATAL_REMAINING: Duration = Duration::from_secs(3600);
#[derive(Default)]
pub struct ToastQueue {
active: Vec<Toast>,
pending: VecDeque<Toast>,
}
impl ToastQueue {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, t: Toast) {
if t.text().is_empty() {
return;
}
let last = self.pending.back_mut().or_else(|| self.active.last_mut());
if let Some(prev) = last
&& prev.level() == t.level()
&& prev.text() == t.text()
{
prev.reset_shown_at();
return;
}
if self.active.len() < MAX_ACTIVE {
self.active.push(t);
} else {
if self.pending.len() >= MAX_PENDING {
self.pending.pop_front();
}
self.pending.push_back(t);
}
}
pub fn tick(&mut self) {
self.active
.retain(|t| t.level() == Level::Fatal || t.shown_at().elapsed() < TTL);
while self.active.len() < MAX_ACTIVE {
match self.pending.pop_front() {
Some(mut next) => {
next.reset_shown_at();
self.active.push(next);
}
None => break,
}
}
}
pub fn active(&self) -> &[Toast] {
&self.active
}
pub fn remaining(&self) -> Option<Duration> {
if self.active.is_empty() {
return None;
}
let mut min: Option<Duration> = None;
let mut has_fatal = false;
for t in &self.active {
if t.level() == Level::Fatal {
has_fatal = true;
continue;
}
let elapsed = t.shown_at().elapsed();
let rem = if elapsed >= TTL {
Duration::ZERO
} else {
TTL - elapsed
};
min = Some(match min {
Some(m) => m.min(rem),
None => rem,
});
}
match (min, has_fatal) {
(Some(d), _) => Some(d),
(None, true) => Some(FATAL_REMAINING),
(None, false) => None,
}
}
pub fn dismiss_fatal(&mut self) -> bool {
if let Some(idx) = self.active.iter().position(|t| t.level() == Level::Fatal) {
self.active.remove(idx);
true
} else {
false
}
}
pub fn has_fatal(&self) -> bool {
self.active.iter().any(|t| t.level() == Level::Fatal)
}
#[allow(dead_code)]
pub fn clear(&mut self) {
self.active.clear();
self.pending.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_fills_active_then_pending() {
let mut q = ToastQueue::new();
for i in 0..5 {
q.push(Toast::info(format!("t{i}")));
}
assert_eq!(q.active.len(), 3);
assert_eq!(q.pending.len(), 2);
let texts: Vec<&str> = q.active.iter().map(|t| t.text()).collect();
assert_eq!(texts, vec!["t0", "t1", "t2"]);
}
#[test]
fn empty_text_is_dropped() {
let mut q = ToastQueue::new();
q.push(Toast::info(""));
assert!(q.active.is_empty());
assert!(q.pending.is_empty());
}
#[test]
fn tick_promotes_pending_when_active_expires() {
let mut q = ToastQueue::new();
let mut a = Toast::info("a");
a.shown_at = Instant::now() - TTL - Duration::from_millis(10);
let mut b = Toast::info("b");
b.shown_at = Instant::now() - TTL - Duration::from_millis(10);
q.active.push(a);
q.active.push(b);
q.push(Toast::info("c"));
q.push(Toast::info("d"));
q.push(Toast::info("e"));
assert_eq!(q.pending.len(), 2);
q.tick();
let texts: Vec<&str> = q.active.iter().map(|t| t.text()).collect();
assert_eq!(texts, vec!["c", "d", "e"]);
assert!(q.pending.is_empty());
}
#[test]
fn fatal_stays_through_tick() {
let mut q = ToastQueue::new();
let mut f = Toast::fatal("stuck");
f.shown_at = Instant::now() - TTL - Duration::from_secs(10);
q.active.push(f);
q.tick();
assert_eq!(q.active.len(), 1);
assert_eq!(q.active[0].text(), "stuck");
}
#[test]
fn dismiss_fatal_drops_one_fatal_only() {
let mut q = ToastQueue::new();
q.push(Toast::info("a"));
q.push(Toast::fatal("boom"));
q.push(Toast::info("c"));
assert!(q.dismiss_fatal());
let texts: Vec<&str> = q.active.iter().map(|t| t.text()).collect();
assert_eq!(texts, vec!["a", "c"]);
assert!(!q.dismiss_fatal());
}
#[test]
fn consecutive_duplicates_refresh_shown_at_instead_of_stacking() {
let mut q = ToastQueue::new();
q.push(Toast::error("nothing to repeat"));
let first_at = q.active[0].shown_at;
std::thread::sleep(Duration::from_millis(5));
for _ in 0..50 {
q.push(Toast::error("nothing to repeat"));
}
assert_eq!(q.active.len(), 1);
assert!(q.pending.is_empty());
assert!(q.active[0].shown_at > first_at);
}
#[test]
fn distinct_text_still_stacks() {
let mut q = ToastQueue::new();
q.push(Toast::error("a"));
q.push(Toast::error("b"));
q.push(Toast::error("a"));
assert_eq!(q.active.len(), 3);
}
#[test]
fn tick_removes_after_real_ttl_elapses() {
let mut q = ToastQueue::new();
q.push(Toast::error("ephemeral"));
assert_eq!(q.active.len(), 1);
std::thread::sleep(TTL + Duration::from_millis(50));
q.tick();
assert!(q.active.is_empty(), "still active: {:?}", q.active.len());
}
#[test]
fn remaining_picks_soonest_non_fatal() {
let mut q = ToastQueue::new();
let mut a = Toast::info("a");
a.shown_at = Instant::now() - Duration::from_millis(2_500);
let mut b = Toast::info("b");
b.shown_at = Instant::now() - Duration::from_millis(500);
q.active.push(a);
q.active.push(b);
let rem = q.remaining().unwrap();
assert!(rem < Duration::from_millis(700), "got {:?}", rem);
}
}