Files
owlen/crates/owlen-tui/src/toast.rs

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());
}
}