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#[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}