use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::ui::sanitize::scrub_ctrl;
use crate::ui::theme::Level;
const TTL_INFO: Duration = Duration::from_secs(4);
const TTL_WARNING: Duration = Duration::from_secs(8);
const TTL_ERROR: Duration = Duration::from_secs(12);
const MAX_ACTIVE: usize = 3;
const LOG_CAPACITY: usize = 50;
#[derive(Debug, Clone)]
pub struct Toast {
pub level: Level,
pub text: String,
pub at: Instant,
}
impl Toast {
fn ttl(&self) -> Duration {
match self.level {
Level::Error => TTL_ERROR,
Level::Warning => TTL_ERROR.min(TTL_WARNING),
_ => TTL_INFO,
}
}
fn is_expired(&self) -> bool {
self.at.elapsed() >= self.ttl()
}
}
#[derive(Debug, Default)]
pub struct Notifier {
active: Vec<Toast>,
log: VecDeque<Toast>,
}
impl Notifier {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, level: Level, text: impl Into<String>) {
let toast = Toast {
level,
text: scrub_ctrl(&text.into()).into_owned(),
at: Instant::now(),
};
self.log.push_back(toast.clone());
while self.log.len() > LOG_CAPACITY {
self.log.pop_front();
}
self.active.push(toast);
while self.active.len() > MAX_ACTIVE {
self.active.remove(0);
}
}
pub fn info(&mut self, text: impl Into<String>) {
self.push(Level::Info, text);
}
pub fn success(&mut self, text: impl Into<String>) {
self.push(Level::Success, text);
}
pub fn warn(&mut self, text: impl Into<String>) {
self.push(Level::Warning, text);
}
pub fn error(&mut self, text: impl Into<String>) {
self.push(Level::Error, text);
}
pub fn active(&self) -> &[Toast] {
&self.active
}
pub fn latest(&self) -> Option<&Toast> {
self.active.last()
}
pub fn history(&self) -> &VecDeque<Toast> {
&self.log
}
pub fn expire(&mut self) -> bool {
let before = self.active.len();
self.active.retain(|t| !t.is_expired());
before != self.active.len()
}
pub fn next_deadline(&self) -> Option<Duration> {
self.active
.iter()
.map(|t| t.ttl().saturating_sub(t.at.elapsed()))
.min()
}
pub fn dismiss_all(&mut self) {
self.active.clear();
}
pub fn is_empty(&self) -> bool {
self.active.is_empty()
}
#[cfg(test)]
pub fn backdate_for_test(&mut self, by: Duration) {
for t in &mut self.active {
t.at -= by;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_makes_a_toast_visible() {
let mut n = Notifier::new();
n.success("Container nginx stopped");
assert_eq!(n.active().len(), 1);
assert_eq!(n.latest().unwrap().level, Level::Success);
assert_eq!(n.latest().unwrap().text, "Container nginx stopped");
}
#[test]
fn severity_is_carried_not_guessed() {
let mut n = Notifier::new();
n.error("Could not reach the daemon");
assert_eq!(n.latest().unwrap().level, Level::Error);
n.success("failed-over to the replica");
assert_eq!(
n.latest().unwrap().level,
Level::Success,
"wording must not override the declared severity"
);
}
#[test]
fn control_characters_are_scrubbed() {
let mut n = Notifier::new();
n.info("evil\x1b[31mprocess\x07");
let text = &n.latest().unwrap().text;
assert!(!text.contains('\x1b'), "escape sequence survived: {text:?}");
assert!(!text.contains('\x07'));
}
#[test]
fn stack_is_capped_but_the_log_is_not() {
let mut n = Notifier::new();
for i in 0..10 {
n.info(format!("message {i}"));
}
assert_eq!(n.active().len(), MAX_ACTIVE);
assert_eq!(n.history().len(), 10);
assert_eq!(n.latest().unwrap().text, "message 9");
}
#[test]
fn log_is_capped_at_capacity() {
let mut n = Notifier::new();
for i in 0..(LOG_CAPACITY + 20) {
n.info(format!("m{i}"));
}
assert_eq!(n.history().len(), LOG_CAPACITY);
assert_eq!(
n.history().back().unwrap().text,
format!("m{}", LOG_CAPACITY + 19)
);
}
#[test]
fn expire_drops_old_toasts_and_reports_the_change() {
let mut n = Notifier::new();
n.info("stale");
n.active[0].at = Instant::now() - TTL_INFO - Duration::from_secs(1);
assert!(n.expire(), "expiring a toast must report a change");
assert!(n.is_empty());
assert!(!n.expire(), "a second call must report no change");
}
#[test]
fn errors_outlive_info_messages() {
let mut n = Notifier::new();
n.info("info");
n.error("error");
let age = TTL_INFO + Duration::from_secs(1);
for t in &mut n.active {
t.at = Instant::now() - age;
}
n.expire();
assert_eq!(n.active().len(), 1);
assert_eq!(n.active()[0].level, Level::Error);
}
#[test]
fn dismiss_clears_the_screen_but_keeps_the_log() {
let mut n = Notifier::new();
n.error("boom");
n.dismiss_all();
assert!(n.is_empty());
assert_eq!(n.history().len(), 1, "Ctrl+L must still find it");
}
#[test]
fn next_deadline_is_none_when_idle() {
let mut n = Notifier::new();
assert_eq!(n.next_deadline(), None);
n.info("x");
assert!(n.next_deadline().is_some_and(|d| d <= TTL_INFO));
}
}