Skip to main content

basalt_tui/
toast.rs

1use std::time::{Duration, Instant};
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Color, Style, Stylize},
7    text::{Line, Span},
8    widgets::{Block, BorderType, Clear, Paragraph, Widget},
9};
10
11use crate::{
12    app::Message as AppMessage,
13    config::{Symbols, Theme},
14};
15
16pub const TOAST_WIDTH: u16 = 40;
17
18#[derive(Clone, PartialEq, Debug)]
19pub struct Toast {
20    level: Option<ToastLevel>,
21    pub(super) message: String,
22    pub icon: String,
23    created_at: Instant,
24    duration: Duration,
25    width: usize,
26    pub border_type: BorderType,
27    pub theme: Theme,
28}
29
30impl Toast {
31    pub fn new(message: &str, duration: Duration) -> Self {
32        Self {
33            message: message.to_string(),
34            duration,
35            ..Default::default()
36        }
37    }
38
39    pub fn info(message: &str, duration: Duration) -> Self {
40        Self {
41            level: Some(ToastLevel::Info),
42            ..Toast::new(message, duration)
43        }
44    }
45
46    pub fn warn(message: &str, duration: Duration) -> Self {
47        Self {
48            level: Some(ToastLevel::Warning),
49            ..Toast::new(message, duration)
50        }
51    }
52
53    pub fn error(message: &str, duration: Duration) -> Self {
54        Self {
55            level: Some(ToastLevel::Error),
56            ..Toast::new(message, duration)
57        }
58    }
59
60    pub fn success(message: &str, duration: Duration) -> Self {
61        Self {
62            level: Some(ToastLevel::Success),
63            ..Toast::new(message, duration)
64        }
65    }
66
67    pub fn level_icon(&self, symbols: &Symbols) -> String {
68        match &self.level {
69            Some(ToastLevel::Success) => symbols.toast_success.clone(),
70            Some(ToastLevel::Info) => symbols.toast_info.clone(),
71            Some(ToastLevel::Error) => symbols.toast_error.clone(),
72            Some(ToastLevel::Warning) => symbols.toast_warning.clone(),
73            None => String::default(),
74        }
75    }
76
77    pub fn is_expired(&self) -> bool {
78        self.created_at.elapsed() >= self.duration
79    }
80
81    pub fn height(&self) -> u16 {
82        let content_width = TOAST_WIDTH.saturating_sub(6) as usize;
83        let wrapped = textwrap::wrap(&self.message, content_width);
84        wrapped.len().max(1) as u16 + 2
85    }
86}
87
88impl Widget for Toast {
89    fn render(self, area: Rect, buf: &mut Buffer)
90    where
91        Self: Sized,
92    {
93        let height = self.height();
94        let color = self
95            .level
96            .as_ref()
97            .map(|l| l.color(&self.theme))
98            .unwrap_or(self.theme.text);
99
100        let block = Block::bordered()
101            .border_type(self.border_type)
102            .border_style(Style::new().fg(color))
103            .style(Style::new().fg(self.theme.text).bg(self.theme.background));
104
105        let toast_area = Rect {
106            x: area.x,
107            y: area.y,
108            width: TOAST_WIDTH.min(area.width),
109            height: height.min(area.height),
110        };
111
112        Clear.render(toast_area, buf);
113
114        let content_width = TOAST_WIDTH.saturating_sub(6) as usize;
115        let wrapped = textwrap::wrap(&self.message, content_width);
116
117        let lines: Vec<Line> = wrapped
118            .iter()
119            .enumerate()
120            .map(|(i, line)| {
121                if i == 0 {
122                    Line::from(vec![
123                        Span::from(" "),
124                        Span::from(self.icon.clone()).fg(color),
125                        Span::from(" "),
126                        Span::from(line.to_string()),
127                    ])
128                } else {
129                    Line::from(format!("   {line}"))
130                }
131            })
132            .collect();
133
134        Paragraph::new(lines).block(block).render(toast_area, buf);
135    }
136}
137
138impl Default for Toast {
139    fn default() -> Self {
140        Self {
141            level: Option::default(),
142            message: String::default(),
143            icon: String::default(),
144            created_at: Instant::now(),
145            duration: Duration::default(),
146            border_type: BorderType::default(),
147            width: 30,
148            theme: Theme::default(),
149        }
150    }
151}
152
153#[derive(Clone, PartialEq, Debug)]
154pub enum ToastLevel {
155    Success,
156    Info,
157    Warning,
158    Error,
159}
160
161impl ToastLevel {
162    pub fn icon(&self) -> &'static str {
163        match self {
164            ToastLevel::Success => "✓",
165            ToastLevel::Info => "ⓘ",
166            ToastLevel::Error => "✗",
167            ToastLevel::Warning => "⚠",
168        }
169    }
170
171    pub fn color(&self, theme: &Theme) -> Color {
172        match self {
173            ToastLevel::Success => theme.success,
174            ToastLevel::Info => theme.info,
175            ToastLevel::Error => theme.error,
176            ToastLevel::Warning => theme.warning,
177        }
178    }
179}
180
181// A Toast owns a full Theme by value (~200 bytes); the size gap to `Tick` is
182// expected and boxing every construction site is not worth it.
183#[allow(clippy::large_enum_variant)]
184#[derive(Clone, PartialEq, Debug)]
185pub enum Message {
186    Create(Toast),
187    Tick,
188}
189
190pub fn update<'a>(message: Message, state: &mut Vec<Toast>) -> Option<AppMessage<'a>> {
191    match message {
192        Message::Create(toast) => {
193            state.push(toast);
194        }
195        Message::Tick => {
196            state.retain(|toast| !toast.is_expired());
197        }
198    };
199    None
200}
201
202#[cfg(test)]
203mod tests {
204    use std::{thread::sleep, time::Duration};
205
206    use super::*;
207    use insta::assert_snapshot;
208    use ratatui::{backend::TestBackend, Terminal};
209
210    use crate::toast::{update, Message, Toast};
211
212    #[test]
213    fn test_toast_update_expired() {
214        let mut state = vec![];
215        update(Message::Create(Toast::default()), &mut state);
216        assert_eq!(state.len(), 1);
217        sleep(Duration::from_millis(1));
218        update(Message::Tick, &mut state);
219        assert_eq!(state.len(), 0);
220    }
221
222    #[test]
223    fn test_toast_update_not_expired() {
224        let mut state = vec![];
225        update(
226            Message::Create(Toast::new("Toast B", Duration::from_secs(10))),
227            &mut state,
228        );
229        update(Message::Tick, &mut state);
230        assert_eq!(state.len(), 1);
231    }
232
233    #[test]
234    fn test_toast_render() {
235        let width = 50;
236        let height = 3;
237        let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
238
239        let tests: Vec<(&str, Toast)> = vec![
240            ("info", Toast::info("File saved", Duration::from_secs(5))),
241            (
242                "error",
243                Toast::error("Failed to save file", Duration::from_secs(5)),
244            ),
245            (
246                "warning",
247                Toast::warn("Unsaved changes", Duration::from_secs(5)),
248            ),
249            (
250                "success",
251                Toast::success("Operation complete", Duration::from_secs(5)),
252            ),
253            (
254                "long_message",
255                Toast::info(
256                    "This is a really long message that should be truncated",
257                    Duration::from_secs(5),
258                ),
259            ),
260            (
261                "no_level",
262                Toast::new("Plain toast", Duration::from_secs(5)),
263            ),
264        ];
265
266        tests.into_iter().for_each(|(name, mut toast)| {
267            _ = terminal.clear();
268            terminal
269                .draw(|frame| {
270                    toast.icon = toast.level_icon(&Symbols::unicode());
271                    toast.render(frame.area(), frame.buffer_mut());
272                })
273                .unwrap();
274            assert_snapshot!(name, terminal.backend());
275        });
276    }
277}