Skip to main content

tui_kit/
toast.rs

1use std::time::Instant;
2
3use ratatui::{
4    layout::Rect,
5    style::{Color, Style},
6    widgets::{Block, Borders, Clear, Paragraph, Wrap},
7    Frame,
8};
9
10use crate::Theme;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum ToastLevel {
14    Success,
15    Warning,
16    Error,
17}
18
19pub struct Toast {
20    pub message: String,
21    pub level: ToastLevel,
22    created_at: Instant,
23    pub duration_ms: u64,
24}
25
26impl Toast {
27    pub fn new(message: String, level: ToastLevel, duration_ms: u64) -> Self {
28        Self {
29            message,
30            level,
31            created_at: Instant::now(),
32            duration_ms,
33        }
34    }
35
36    pub fn is_expired(&self) -> bool {
37        self.created_at.elapsed().as_millis() as u64 >= self.duration_ms
38    }
39
40    /// True when in the last third of life — used to apply the ANSI-8 fade color.
41    /// Capped so that even a 1 s toast shows its full color for the first 2/3 of its life.
42    fn is_fading(&self) -> bool {
43        let elapsed_ms = self.created_at.elapsed().as_millis() as u64;
44        let fade_start_ms = self.duration_ms * 2 / 3;
45        elapsed_ms >= fade_start_ms
46    }
47
48    /// Number of content lines needed to display the message at `content_width`, capped at 3.
49    fn content_lines(&self, content_width: usize) -> u16 {
50        if content_width == 0 {
51            return 1;
52        }
53        let chars = self.message.chars().count();
54        let lines = (chars + content_width - 1) / content_width;
55        (lines as u16).max(1).min(3)
56    }
57}
58
59/// Render all active toasts stacked in the top-right corner, newest on top.
60pub fn render_toasts(f: &mut Frame, toasts: &[Toast], theme: &Theme) {
61    const TOAST_WIDTH: u16 = 56;
62    const MARGIN_RIGHT: u16 = 1;
63    const MARGIN_TOP: u16 = 1;
64    // Inner content width: TOAST_WIDTH minus 2 borders minus 2 side padding
65    const CONTENT_WIDTH: usize = (TOAST_WIDTH - 4) as usize;
66
67    let area = f.area();
68    let x = area.width.saturating_sub(TOAST_WIDTH + MARGIN_RIGHT);
69
70    let mut y = MARGIN_TOP;
71    for toast in toasts.iter().rev() {
72        let content_lines = toast.content_lines(CONTENT_WIDTH);
73        let toast_height = 2 + content_lines; // top border + lines + bottom border
74
75        if y + toast_height > area.height {
76            break;
77        }
78
79        let normal_style = match toast.level {
80            ToastLevel::Success => theme.success,
81            ToastLevel::Warning => theme.border_warning,
82            ToastLevel::Error   => theme.border_error,
83        };
84        let style = if toast.is_fading() {
85            Style::default().fg(Color::DarkGray)
86        } else {
87            normal_style
88        };
89
90        let toast_area = Rect { x, y, width: TOAST_WIDTH, height: toast_height };
91
92        let block = Block::default()
93            .borders(Borders::ALL)
94            .border_style(style);
95
96        let body = Paragraph::new(toast.message.as_str())
97            .style(style)
98            .block(block)
99            .wrap(Wrap { trim: true });
100
101        f.render_widget(Clear, toast_area);
102        f.render_widget(body, toast_area);
103
104        y += toast_height + 1; // gap between toasts
105    }
106}