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}