use super::theme;
use crate::util;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph, Widget};
use std::time::{Duration, Instant};
const HELD: usize = 4;
const MAX_WIDTH: u16 = 72;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Info,
Failure,
}
impl Severity {
fn of(text: &str) -> Severity {
const OPENINGS: [&str; 4] = ["Could not", "Couldn't", "Cannot", "Failed"];
match OPENINGS.iter().any(|o| text.starts_with(o)) || text.contains(" failed") {
true => Severity::Failure,
false => Severity::Info,
}
}
}
#[derive(Debug, Clone)]
pub struct Toast {
pub text: String,
pub count: u32,
pub at: Instant,
pub severity: Severity,
}
impl Toast {
fn ttl(&self) -> Duration {
let reading = 3.0 + util::cells(&self.text) as f64 / 15.0;
let extra = match self.severity {
Severity::Info => 0.0,
Severity::Failure => 3.0,
};
Duration::from_secs_f64(reading.min(10.0) + extra)
}
fn provisional(&self) -> bool {
self.text.ends_with('…')
}
fn label(&self) -> String {
match self.count {
1 => self.text.clone(),
n => format!("{} ×{n}", self.text),
}
}
}
#[derive(Debug, Default)]
pub struct Toasts {
stack: Vec<Toast>,
}
impl Toasts {
pub fn push(&mut self, text: String) {
self.push_at(text, Instant::now());
}
fn push_at(&mut self, text: String, now: Instant) {
if let Some(newest) = self.stack.first_mut() {
if newest.text == text {
newest.count += 1;
newest.at = now;
return;
}
if newest.provisional() {
self.stack.remove(0);
}
}
let severity = Severity::of(&text);
self.stack.insert(
0,
Toast {
text,
count: 1,
at: now,
severity,
},
);
self.stack.truncate(HELD);
}
pub fn expire(&mut self, now: Instant) -> bool {
let before = self.stack.len();
self.stack
.retain(|t| now.saturating_duration_since(t.at) <= t.ttl());
self.stack.len() != before
}
#[cfg(test)]
pub fn latest(&self) -> Option<&str> {
self.stack.first().map(|t| t.text.as_str())
}
pub fn iter(&self) -> impl Iterator<Item = &Toast> {
self.stack.iter()
}
}
fn pill(severity: Severity) -> Style {
let style = match theme::no_color() {
true => Style::default().add_modifier(Modifier::REVERSED),
false => Style::default()
.bg(theme::colors().header_bg)
.fg(theme::colors().value),
};
match severity {
Severity::Info => style,
Severity::Failure => style.add_modifier(Modifier::BOLD),
}
}
fn marker(severity: Severity) -> Span<'static> {
let (glyph, color) = match (severity, theme::no_color()) {
(Severity::Info, _) => ("▌", theme::colors().cost_low),
(Severity::Failure, true) => ("!", theme::colors().cost_mid),
(Severity::Failure, false) => ("▌", theme::colors().cost_mid),
};
Span::styled(glyph, pill(severity).fg(color))
}
fn wrap(text: &str, width: usize) -> Vec<String> {
if util::cells(text) <= width {
return vec![text.to_string()];
}
let mut first = String::new();
let mut rest = text;
for word in text.split_inclusive(' ') {
if util::cells(&first) + util::cells(word.trim_end()) > width {
break;
}
first.push_str(word);
rest = &rest[word.len()..];
}
if first.is_empty() {
return vec![util::truncate(text, width)];
}
vec![
first.trim_end().to_string(),
util::truncate(rest.trim_start(), width),
]
}
pub fn draw(buf: &mut Buffer, region: Rect, toasts: &Toasts) {
let region = region.intersection(buf.area);
const CHROME: u16 = 3;
let width = MAX_WIDTH.min(region.width);
if width <= CHROME {
return;
}
let mut y = region.y;
for toast in toasts.iter() {
let lines = wrap(&toast.label(), (width - CHROME) as usize);
let height = lines.len() as u16;
if y + height > region.bottom() {
break;
}
let w = lines.iter().map(|l| util::cells(l)).max().unwrap_or(0) as u16 + CHROME;
let rect = Rect {
x: region.right() - w,
y,
width: w,
height,
};
let style = pill(toast.severity);
let text: Vec<Line> = lines
.into_iter()
.map(|l| {
Line::from(vec![
marker(toast.severity),
Span::styled(format!(" {l}"), style),
])
})
.collect();
Clear.render(rect, buf);
Paragraph::new(text).style(style).render(rect, buf);
y += height;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn said(toasts: &Toasts) -> Vec<String> {
toasts.iter().map(Toast::label).collect()
}
fn rows(buf: &Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
#[test]
fn toasts_stack_newest_first_and_hold_only_a_few() {
let mut toasts = Toasts::default();
for n in 1..=6 {
toasts.push(format!("message {n}"));
}
assert_eq!(
said(&toasts),
["message 6", "message 5", "message 4", "message 3"]
);
assert_eq!(toasts.latest(), Some("message 6"));
}
#[test]
fn a_repeated_message_collapses_into_a_count() {
let mut toasts = Toasts::default();
toasts.push("Refresh failed".into());
toasts.push("Refresh failed".into());
toasts.push("Refresh failed".into());
assert_eq!(said(&toasts), ["Refresh failed ×3"]);
toasts.push("Saved".into());
toasts.push("Refresh failed".into());
assert_eq!(
said(&toasts),
["Refresh failed", "Saved", "Refresh failed ×3"]
);
}
#[test]
fn a_message_under_way_gives_way_to_the_next() {
let mut toasts = Toasts::default();
toasts.push("Tab renamed to api".into());
toasts.push("Deleting session 1234…".into());
toasts.push("Deleted session".into());
assert_eq!(said(&toasts), ["Deleted session", "Tab renamed to api"]);
}
#[test]
fn toasts_expire_and_longer_ones_last_longer() {
let start = Instant::now();
let mut toasts = Toasts::default();
toasts.push_at("Saved".into(), start);
toasts.push_at(
"Restarted 3 tabs, skipped 1 mid-turn — it restarts when the turn ends".into(),
start,
);
toasts.push_at("Could not save: permission denied".into(), start);
assert!(!toasts.expire(start + Duration::from_secs(2)));
assert_eq!(toasts.iter().count(), 3);
assert!(toasts.expire(start + Duration::from_secs(4)));
assert_eq!(
toasts.latest(),
Some("Could not save: permission denied"),
"the failure should outlast the confirmation"
);
assert!(!said(&toasts).contains(&"Saved".to_string()));
assert_eq!(toasts.iter().count(), 2);
assert!(toasts.expire(start + Duration::from_secs(30)));
assert_eq!(toasts.latest(), None);
}
#[test]
fn a_repeat_restarts_the_clock() {
let start = Instant::now();
let mut toasts = Toasts::default();
toasts.push_at("Saved".into(), start);
toasts.push_at("Saved".into(), start + Duration::from_secs(3));
toasts.expire(start + Duration::from_secs(5));
assert_eq!(said(&toasts), ["Saved ×2"]);
}
#[test]
fn failures_are_read_off_their_wording() {
assert_eq!(
Severity::of("Could not serve: port in use"),
Severity::Failure
);
assert_eq!(
Severity::of("Restarted 2 tabs, 1 failed"),
Severity::Failure
);
assert_eq!(Severity::of("Saved ~/.config/cctop"), Severity::Info);
}
#[test]
fn the_stack_draws_newest_at_the_top_against_the_right_edge() {
let mut toasts = Toasts::default();
toasts.push("older".into());
toasts.push("newer".into());
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
draw(&mut buf, Rect::new(0, 0, 40, 4), &toasts);
let rows = rows(&buf);
assert!(rows[0].ends_with("▌ newer "), "{rows:?}");
assert!(rows[1].ends_with("▌ older "), "{rows:?}");
assert!(rows[2].trim().is_empty(), "{rows:?}");
}
#[test]
fn a_long_message_wraps_and_the_region_bounds_the_stack() {
let mut toasts = Toasts::default();
toasts.push("third".into());
toasts.push("second".into());
toasts.push("Could not open a tunnel: cloudflared exited".into());
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
draw(&mut buf, Rect::new(0, 0, 30, 3), &toasts);
let rows = rows(&buf);
assert!(rows[0].contains("Could not open a tunnel:"), "{rows:?}");
assert!(rows[1].contains("cloudflared exited"), "{rows:?}");
assert!(rows[2].contains("second"), "{rows:?}");
assert!(!rows.concat().contains("third"), "{rows:?}");
}
}