Skip to main content

rtcom_tui/
toast.rs

1//! Timed toast notifications for the TUI.
2//!
3//! A [`ToastQueue`] holds up to three [`Toast`] entries with an
4//! [`Instant`]-based expiration. The renderer draws visible toasts in
5//! a single row each, stacked top-to-bottom with the newest entry at
6//! the top. Expiration is driven by calls to [`ToastQueue::tick`] —
7//! the TUI runner invokes it on a periodic timer so toasts disappear
8//! even when no key / bus events arrive.
9//!
10//! Toasts overlay every other chrome including the modal dialog, so
11//! outcome messages (`Event::ProfileSaved`, `Event::ProfileLoadFailed`,
12//! `Event::Error`) are always visible regardless of menu state.
13//!
14//! Symbol choice: ASCII prefixes (`i  `, `!  `, `x  `) keep snapshot
15//! output deterministic across terminals that render unicode
16//! differently. Colour distinguishes severity.
17
18use std::time::{Duration, Instant};
19
20use ratatui::{
21    buffer::Buffer,
22    layout::Rect,
23    style::{Color, Modifier, Style},
24    text::{Line, Span},
25    widgets::{Paragraph, Widget},
26};
27
28/// Severity level for a toast. Drives the foreground colour.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ToastLevel {
31    /// Informational (e.g., "profile saved"). Rendered in green.
32    Info,
33    /// Warning. Rendered in yellow.
34    Warn,
35    /// Error (e.g., "profile IO failed"). Rendered in red.
36    Error,
37}
38
39/// A single toast message with an expiration time.
40#[derive(Debug, Clone)]
41pub struct Toast {
42    /// User-visible text rendered to the right of the severity prefix.
43    pub message: String,
44    /// Severity level; drives the colour.
45    pub level: ToastLevel,
46    /// Absolute wall-clock instant after which the toast is dropped
47    /// by [`ToastQueue::tick`].
48    pub expires_at: Instant,
49}
50
51impl Toast {
52    /// Compute the [`Style`] used for both the severity prefix and
53    /// the message text.
54    #[must_use]
55    pub fn style(&self) -> Style {
56        let fg = match self.level {
57            ToastLevel::Info => Color::Green,
58            ToastLevel::Warn => Color::Yellow,
59            ToastLevel::Error => Color::Red,
60        };
61        Style::default().fg(fg).add_modifier(Modifier::BOLD)
62    }
63}
64
65/// Bounded queue of visible toasts.
66///
67/// When full, pushing a new toast drops the oldest entry to make
68/// room. Expired toasts are dropped by [`ToastQueue::tick`].
69#[derive(Debug)]
70pub struct ToastQueue {
71    toasts: Vec<Toast>,
72    max_visible: usize,
73}
74
75impl Default for ToastQueue {
76    fn default() -> Self {
77        Self {
78            toasts: Vec::new(),
79            max_visible: 3,
80        }
81    }
82}
83
84impl ToastQueue {
85    /// Default visible lifetime used by [`ToastQueue::push`].
86    pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(3);
87
88    /// Construct an empty queue with the default capacity (3 visible).
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Push a new toast with the default 3-second lifetime. Drops the
95    /// oldest visible toast if the queue is already at capacity.
96    pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
97        self.push_with_lifetime(message, level, Self::DEFAULT_LIFETIME);
98    }
99
100    /// Push a toast with a caller-supplied lifetime. Mainly useful for
101    /// tests that need a short-lived toast to exercise [`Self::tick`].
102    pub fn push_with_lifetime(
103        &mut self,
104        message: impl Into<String>,
105        level: ToastLevel,
106        lifetime: Duration,
107    ) {
108        let toast = Toast {
109            message: message.into(),
110            level,
111            expires_at: Instant::now() + lifetime,
112        };
113        if self.toasts.len() >= self.max_visible {
114            self.toasts.remove(0);
115        }
116        self.toasts.push(toast);
117    }
118
119    /// Drop expired toasts. Call on each render tick so entries
120    /// disappear at the advertised lifetime even when no key / bus
121    /// events arrive.
122    pub fn tick(&mut self) {
123        let now = Instant::now();
124        self.toasts.retain(|t| t.expires_at > now);
125    }
126
127    /// Borrow the list of currently-visible toasts, oldest first.
128    //
129    // Not `const fn`: `&self.toasts` (`Vec<Toast>` → `&[Toast]`) goes
130    // through deref coercion, which is not yet const as of Rust 1.86.
131    #[must_use]
132    #[allow(clippy::missing_const_for_fn)]
133    pub fn visible(&self) -> &[Toast] {
134        &self.toasts
135    }
136
137    /// Number of currently-visible toasts.
138    #[must_use]
139    pub const fn visible_count(&self) -> usize {
140        self.toasts.len()
141    }
142
143    /// `true` when no toasts are currently visible.
144    #[must_use]
145    pub const fn is_empty(&self) -> bool {
146        self.toasts.is_empty()
147    }
148}
149
150/// Render the queue's visible toasts into `area`.
151///
152/// Each toast occupies one row; the newest entry is drawn at the top
153/// of the area, older entries below. Rows past `area.height` are
154/// skipped silently.
155pub fn render_toasts(queue: &ToastQueue, area: Rect, buf: &mut Buffer) {
156    if area.width == 0 || area.height == 0 {
157        return;
158    }
159    // Newest at top: iterate in reverse insertion order.
160    for (i, toast) in queue.visible().iter().rev().enumerate() {
161        let offset = u16::try_from(i).unwrap_or(u16::MAX);
162        if offset >= area.height {
163            break;
164        }
165        let row = Rect {
166            x: area.x,
167            y: area.y + offset,
168            width: area.width,
169            height: 1,
170        };
171        let prefix = match toast.level {
172            ToastLevel::Info => "i  ",
173            ToastLevel::Warn => "!  ",
174            ToastLevel::Error => "x  ",
175        };
176        let style = toast.style();
177        let line = Line::from(vec![
178            Span::styled(prefix, style),
179            Span::styled(toast.message.as_str(), style),
180        ]);
181        Paragraph::new(line).render(row, buf);
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use std::thread;
189
190    #[test]
191    fn queue_starts_empty() {
192        let q = ToastQueue::new();
193        assert_eq!(q.visible_count(), 0);
194        assert!(q.is_empty());
195    }
196
197    #[test]
198    fn push_adds_toast() {
199        let mut q = ToastQueue::new();
200        q.push("hello", ToastLevel::Info);
201        assert_eq!(q.visible_count(), 1);
202        assert_eq!(q.visible()[0].message, "hello");
203        assert_eq!(q.visible()[0].level, ToastLevel::Info);
204    }
205
206    #[test]
207    fn tick_removes_expired_toasts() {
208        let mut q = ToastQueue::new();
209        q.push_with_lifetime("short", ToastLevel::Info, Duration::from_millis(10));
210        assert_eq!(q.visible_count(), 1);
211        thread::sleep(Duration::from_millis(25));
212        q.tick();
213        assert_eq!(q.visible_count(), 0);
214        assert!(q.is_empty());
215    }
216
217    #[test]
218    fn tick_keeps_live_toasts() {
219        let mut q = ToastQueue::new();
220        q.push_with_lifetime("live", ToastLevel::Info, Duration::from_secs(60));
221        q.tick();
222        assert_eq!(q.visible_count(), 1);
223    }
224
225    #[test]
226    fn queue_drops_oldest_when_full() {
227        let mut q = ToastQueue::new();
228        // Default max_visible = 3.
229        for i in 0..4 {
230            q.push(format!("t{i}"), ToastLevel::Info);
231        }
232        assert_eq!(q.visible_count(), 3);
233        let msgs: Vec<&str> = q.visible().iter().map(|t| t.message.as_str()).collect();
234        assert_eq!(msgs, vec!["t1", "t2", "t3"]);
235    }
236
237    #[test]
238    fn default_lifetime_is_3_seconds() {
239        assert_eq!(ToastQueue::DEFAULT_LIFETIME, Duration::from_secs(3));
240    }
241
242    #[test]
243    fn level_colours_are_distinct() {
244        let now = Instant::now();
245        let info = Toast {
246            message: String::new(),
247            level: ToastLevel::Info,
248            expires_at: now,
249        };
250        let warn = Toast {
251            message: String::new(),
252            level: ToastLevel::Warn,
253            expires_at: now,
254        };
255        let err = Toast {
256            message: String::new(),
257            level: ToastLevel::Error,
258            expires_at: now,
259        };
260        assert_ne!(info.style().fg, warn.style().fg);
261        assert_ne!(warn.style().fg, err.style().fg);
262        assert_ne!(info.style().fg, err.style().fg);
263    }
264
265    #[test]
266    fn level_styles_are_bold() {
267        let t = Toast {
268            message: String::new(),
269            level: ToastLevel::Info,
270            expires_at: Instant::now(),
271        };
272        assert!(t.style().add_modifier.contains(Modifier::BOLD));
273    }
274
275    #[test]
276    fn render_noop_on_zero_area() {
277        let mut q = ToastQueue::new();
278        q.push("hello", ToastLevel::Info);
279        let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
280        render_toasts(&q, Rect::new(0, 0, 0, 0), &mut buf);
281        // Nothing to assert beyond "does not panic".
282    }
283
284    #[test]
285    fn render_writes_newest_toast_at_top() {
286        let mut q = ToastQueue::new();
287        q.push("first", ToastLevel::Info);
288        q.push("second", ToastLevel::Warn);
289        let area = Rect::new(0, 0, 20, 3);
290        let mut buf = Buffer::empty(area);
291        render_toasts(&q, area, &mut buf);
292        // Row 0 should start with the Warn prefix and "second".
293        let top = buf_row_string(&buf, 0);
294        assert!(top.starts_with("!  second"), "got: {top:?}");
295        // Row 1 should start with the Info prefix and "first".
296        let row1 = buf_row_string(&buf, 1);
297        assert!(row1.starts_with("i  first"), "got: {row1:?}");
298    }
299
300    fn buf_row_string(buf: &Buffer, y: u16) -> String {
301        let area = buf.area;
302        (0..area.width)
303            .map(|x| buf[(area.x + x, area.y + y)].symbol().to_string())
304            .collect::<String>()
305    }
306}