210 lines
5.5 KiB
Rust
210 lines
5.5 KiB
Rust
use std::collections::VecDeque;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Severity level for toast notifications.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ToastLevel {
|
|
Info,
|
|
Success,
|
|
Warning,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Toast {
|
|
pub message: String,
|
|
pub level: ToastLevel,
|
|
created: Instant,
|
|
duration: Duration,
|
|
shortcut_hint: Option<String>,
|
|
}
|
|
|
|
impl Toast {
|
|
fn new(
|
|
message: String,
|
|
level: ToastLevel,
|
|
lifetime: Duration,
|
|
shortcut_hint: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
message,
|
|
level,
|
|
created: Instant::now(),
|
|
duration: lifetime,
|
|
shortcut_hint,
|
|
}
|
|
}
|
|
|
|
fn is_expired(&self, now: Instant) -> bool {
|
|
now.duration_since(self.created) >= self.duration
|
|
}
|
|
|
|
pub fn shortcut(&self) -> Option<&str> {
|
|
self.shortcut_hint.as_deref()
|
|
}
|
|
|
|
pub fn elapsed_fraction(&self, now: Instant) -> f32 {
|
|
let elapsed = now.saturating_duration_since(self.created).as_secs_f32();
|
|
let total = self.duration.as_secs_f32().max(f32::MIN_POSITIVE);
|
|
(elapsed / total).clamp(0.0, 1.0)
|
|
}
|
|
|
|
pub fn remaining_fraction(&self, now: Instant) -> f32 {
|
|
1.0 - self.elapsed_fraction(now)
|
|
}
|
|
|
|
pub fn remaining_duration(&self, now: Instant) -> Duration {
|
|
let elapsed = now.saturating_duration_since(self.created);
|
|
if elapsed >= self.duration {
|
|
Duration::from_secs(0)
|
|
} else {
|
|
self.duration - elapsed
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToastHistoryEntry {
|
|
pub message: String,
|
|
pub level: ToastLevel,
|
|
recorded: Instant,
|
|
pub shortcut_hint: Option<String>,
|
|
}
|
|
|
|
impl ToastHistoryEntry {
|
|
fn new(message: String, level: ToastLevel, shortcut_hint: Option<String>) -> Self {
|
|
Self {
|
|
message,
|
|
level,
|
|
recorded: Instant::now(),
|
|
shortcut_hint,
|
|
}
|
|
}
|
|
|
|
pub fn recorded(&self) -> Instant {
|
|
self.recorded
|
|
}
|
|
}
|
|
|
|
/// Fixed-size toast queue with automatic expiration.
|
|
#[derive(Debug)]
|
|
pub struct ToastManager {
|
|
items: VecDeque<Toast>,
|
|
max_active: usize,
|
|
lifetime: Duration,
|
|
history: VecDeque<ToastHistoryEntry>,
|
|
history_limit: usize,
|
|
}
|
|
|
|
impl Default for ToastManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ToastManager {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
items: VecDeque::new(),
|
|
max_active: 3,
|
|
lifetime: Duration::from_secs(3),
|
|
history: VecDeque::new(),
|
|
history_limit: 20,
|
|
}
|
|
}
|
|
|
|
pub fn with_lifetime(mut self, duration: Duration) -> Self {
|
|
self.lifetime = duration;
|
|
self
|
|
}
|
|
|
|
pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
|
|
self.push_internal(message.into(), level, None);
|
|
}
|
|
|
|
pub fn push_with_hint(
|
|
&mut self,
|
|
message: impl Into<String>,
|
|
level: ToastLevel,
|
|
shortcut_hint: impl Into<String>,
|
|
) {
|
|
self.push_internal(
|
|
message.into(),
|
|
level,
|
|
Some(shortcut_hint.into().trim().to_string()),
|
|
);
|
|
}
|
|
|
|
fn push_internal(&mut self, message: String, level: ToastLevel, shortcut_hint: Option<String>) {
|
|
let toast = Toast::new(message.clone(), level, self.lifetime, shortcut_hint.clone());
|
|
self.items.push_front(toast);
|
|
while self.items.len() > self.max_active {
|
|
self.items.pop_back();
|
|
}
|
|
self.history
|
|
.push_front(ToastHistoryEntry::new(message, level, shortcut_hint));
|
|
if self.history.len() > self.history_limit {
|
|
self.history.pop_back();
|
|
}
|
|
}
|
|
|
|
pub fn retain_active(&mut self) {
|
|
let now = Instant::now();
|
|
self.items.retain(|toast| !toast.is_expired(now));
|
|
}
|
|
|
|
pub fn iter(&self) -> impl Iterator<Item = &Toast> {
|
|
self.items.iter()
|
|
}
|
|
|
|
pub fn history(&self) -> impl Iterator<Item = &ToastHistoryEntry> {
|
|
self.history.iter()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.items.is_empty()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::thread::sleep;
|
|
|
|
#[test]
|
|
fn manager_limits_active_toasts() {
|
|
let mut manager = ToastManager::new();
|
|
manager.push("first", ToastLevel::Info);
|
|
manager.push("second", ToastLevel::Warning);
|
|
manager.push("third", ToastLevel::Success);
|
|
manager.push("fourth", ToastLevel::Error);
|
|
|
|
let collected: Vec<_> = manager.iter().map(|toast| toast.message.clone()).collect();
|
|
assert_eq!(collected.len(), 3);
|
|
assert_eq!(collected[0], "fourth");
|
|
assert_eq!(collected[2], "second");
|
|
}
|
|
|
|
#[test]
|
|
fn manager_expires_toasts_after_lifetime() {
|
|
let mut manager = ToastManager::new().with_lifetime(Duration::from_millis(1));
|
|
manager.push("short lived", ToastLevel::Info);
|
|
assert!(!manager.is_empty());
|
|
sleep(Duration::from_millis(5));
|
|
manager.retain_active();
|
|
assert!(manager.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn manager_tracks_history_and_hints() {
|
|
let mut manager = ToastManager::new();
|
|
manager.push_with_hint("saving project", ToastLevel::Info, "Ctrl+S");
|
|
manager.push("all good", ToastLevel::Success);
|
|
|
|
let latest = manager.history().next().expect("history entry");
|
|
assert_eq!(latest.level, ToastLevel::Success);
|
|
assert!(latest.shortcut_hint.is_none());
|
|
assert!(manager.history().nth(1).unwrap().shortcut_hint.is_some());
|
|
}
|
|
}
|