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