Skip to main content

revue/widget/feedback/
toast.rs

1//! Toast notification widget
2//!
3//! Displays temporary notification messages with different severity levels.
4
5use crate::render::Cell;
6use crate::style::Color;
7use crate::widget::traits::{RenderContext, View, WidgetProps};
8use crate::{impl_props_builders, impl_styled_view};
9
10/// Toast notification level
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum ToastLevel {
13    /// Informational message (blue)
14    #[default]
15    Info,
16    /// Success message (green)
17    Success,
18    /// Warning message (yellow)
19    Warning,
20    /// Error message (red)
21    Error,
22}
23
24impl ToastLevel {
25    /// Get the icon for this level
26    pub fn icon(&self) -> char {
27        match self {
28            ToastLevel::Info => 'ℹ',
29            ToastLevel::Success => '✓',
30            ToastLevel::Warning => '⚠',
31            ToastLevel::Error => '✗',
32        }
33    }
34
35    /// Get the color for this level
36    pub fn color(&self) -> Color {
37        match self {
38            ToastLevel::Info => Color::CYAN,
39            ToastLevel::Success => Color::GREEN,
40            ToastLevel::Warning => Color::YELLOW,
41            ToastLevel::Error => Color::RED,
42        }
43    }
44
45    /// Get the background color for this level
46    pub fn bg_color(&self) -> Color {
47        match self {
48            ToastLevel::Info => Color::rgb(0, 40, 60),
49            ToastLevel::Success => Color::rgb(0, 40, 0),
50            ToastLevel::Warning => Color::rgb(60, 40, 0),
51            ToastLevel::Error => Color::rgb(60, 0, 0),
52        }
53    }
54}
55
56/// Toast position on screen
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub enum ToastPosition {
59    /// Top-left corner
60    TopLeft,
61    /// Top-center
62    TopCenter,
63    /// Top-right corner
64    #[default]
65    TopRight,
66    /// Bottom-left corner
67    BottomLeft,
68    /// Bottom-center
69    BottomCenter,
70    /// Bottom-right corner
71    BottomRight,
72}
73
74/// A toast notification widget
75///
76/// # Example
77///
78/// ```rust,ignore
79/// use revue::prelude::*;
80///
81/// let toast = Toast::new("File saved successfully!")
82///     .level(ToastLevel::Success)
83///     .position(ToastPosition::TopRight);
84/// ```
85pub struct Toast {
86    message: String,
87    level: ToastLevel,
88    position: ToastPosition,
89    width: Option<u16>,
90    show_icon: bool,
91    show_border: bool,
92    props: WidgetProps,
93}
94
95impl Toast {
96    /// Create a new toast with a message
97    pub fn new(message: impl Into<String>) -> Self {
98        Self {
99            message: message.into(),
100            level: ToastLevel::default(),
101            position: ToastPosition::default(),
102            width: None,
103            show_icon: true,
104            show_border: true,
105            props: WidgetProps::new(),
106        }
107    }
108
109    /// Create an info toast
110    pub fn info(message: impl Into<String>) -> Self {
111        Self::new(message).level(ToastLevel::Info)
112    }
113
114    /// Create a success toast
115    pub fn success(message: impl Into<String>) -> Self {
116        Self::new(message).level(ToastLevel::Success)
117    }
118
119    /// Create a warning toast
120    pub fn warning(message: impl Into<String>) -> Self {
121        Self::new(message).level(ToastLevel::Warning)
122    }
123
124    /// Create an error toast
125    pub fn error(message: impl Into<String>) -> Self {
126        Self::new(message).level(ToastLevel::Error)
127    }
128
129    /// Set the toast level
130    pub fn level(mut self, level: ToastLevel) -> Self {
131        self.level = level;
132        self
133    }
134
135    /// Set the toast position
136    pub fn position(mut self, position: ToastPosition) -> Self {
137        self.position = position;
138        self
139    }
140
141    /// Set fixed width
142    pub fn width(mut self, width: u16) -> Self {
143        self.width = Some(width);
144        self
145    }
146
147    /// Show or hide the icon
148    pub fn show_icon(mut self, show: bool) -> Self {
149        self.show_icon = show;
150        self
151    }
152
153    /// Show or hide the border
154    pub fn show_border(mut self, show: bool) -> Self {
155        self.show_border = show;
156        self
157    }
158
159    /// Calculate toast dimensions
160    fn calculate_size(&self, max_width: u16) -> (u16, u16) {
161        let icon_width = if self.show_icon { 2 } else { 0 };
162        let border_width = if self.show_border { 2 } else { 0 };
163        let padding = 2; // 1 char padding on each side
164
165        let content_width = crate::utils::unicode::display_width(&self.message) as u16 + icon_width;
166        let total_width = self.width.unwrap_or(content_width + border_width + padding);
167        let width = total_width.min(max_width);
168
169        // Calculate height based on message wrapping
170        let inner_width = width.saturating_sub(border_width + padding + icon_width);
171        let msg_cols = crate::utils::unicode::display_width(&self.message) as u16;
172        let lines = if inner_width > 0 {
173            msg_cols.saturating_add(inner_width - 1) / inner_width
174        } else {
175            1
176        };
177        let height = lines + if self.show_border { 2 } else { 0 };
178
179        (width, height.max(if self.show_border { 3 } else { 1 }))
180    }
181
182    /// Calculate toast position
183    fn calculate_position(
184        &self,
185        area_width: u16,
186        area_height: u16,
187        toast_width: u16,
188        toast_height: u16,
189    ) -> (u16, u16) {
190        let margin = 1u16;
191
192        let x = match self.position {
193            ToastPosition::TopLeft | ToastPosition::BottomLeft => margin,
194            ToastPosition::TopCenter | ToastPosition::BottomCenter => {
195                area_width.saturating_sub(toast_width) / 2
196            }
197            ToastPosition::TopRight | ToastPosition::BottomRight => {
198                area_width.saturating_sub(toast_width + margin)
199            }
200        };
201
202        let y = match self.position {
203            ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight => margin,
204            ToastPosition::BottomLeft
205            | ToastPosition::BottomCenter
206            | ToastPosition::BottomRight => area_height.saturating_sub(toast_height + margin),
207        };
208
209        (x, y)
210    }
211}
212
213impl View for Toast {
214    crate::impl_view_meta!("Toast");
215
216    fn render(&self, ctx: &mut RenderContext) {
217        let area = ctx.area;
218        if area.width < 5 || area.height < 3 {
219            return;
220        }
221
222        let (toast_width, toast_height) = self.calculate_size(area.width);
223        let (x, y) = self.calculate_position(area.width, area.height, toast_width, toast_height);
224
225        let color = self.level.color();
226        let bg = self.level.bg_color();
227
228        // Draw border
229        if self.show_border {
230            // Top border
231            let mut top_left = Cell::new('╭');
232            top_left.fg = Some(color);
233            top_left.bg = Some(bg);
234            ctx.buffer.set(area.x + x, area.y + y, top_left);
235
236            for i in 1..toast_width.saturating_sub(1) {
237                let mut cell = Cell::new('─');
238                cell.fg = Some(color);
239                cell.bg = Some(bg);
240                ctx.buffer.set(area.x + x + i, area.y + y, cell);
241            }
242
243            let mut top_right = Cell::new('╮');
244            top_right.fg = Some(color);
245            top_right.bg = Some(bg);
246            ctx.buffer
247                .set(area.x + x + toast_width - 1, area.y + y, top_right);
248
249            // Bottom border
250            let mut bottom_left = Cell::new('╰');
251            bottom_left.fg = Some(color);
252            bottom_left.bg = Some(bg);
253            ctx.buffer
254                .set(area.x + x, area.y + y + toast_height - 1, bottom_left);
255
256            for i in 1..toast_width.saturating_sub(1) {
257                let mut cell = Cell::new('─');
258                cell.fg = Some(color);
259                cell.bg = Some(bg);
260                ctx.buffer
261                    .set(area.x + x + i, area.y + y + toast_height - 1, cell);
262            }
263
264            let mut bottom_right = Cell::new('╯');
265            bottom_right.fg = Some(color);
266            bottom_right.bg = Some(bg);
267            ctx.buffer.set(
268                area.x + x + toast_width - 1,
269                area.y + y + toast_height - 1,
270                bottom_right,
271            );
272
273            // Side borders
274            for row in 1..toast_height.saturating_sub(1) {
275                let mut left = Cell::new('│');
276                left.fg = Some(color);
277                left.bg = Some(bg);
278                ctx.buffer.set(area.x + x, area.y + y + row, left);
279
280                let mut right = Cell::new('│');
281                right.fg = Some(color);
282                right.bg = Some(bg);
283                ctx.buffer
284                    .set(area.x + x + toast_width - 1, area.y + y + row, right);
285
286                // Fill background
287                for col in 1..toast_width.saturating_sub(1) {
288                    let mut fill = Cell::new(' ');
289                    fill.bg = Some(bg);
290                    ctx.buffer.set(area.x + x + col, area.y + y + row, fill);
291                }
292            }
293        }
294
295        // Draw content
296        let content_x = x + if self.show_border { 2 } else { 0 };
297        let content_y = y + if self.show_border { 1 } else { 0 };
298
299        // Draw icon
300        if self.show_icon {
301            let mut icon_cell = Cell::new(self.level.icon());
302            icon_cell.fg = Some(color);
303            icon_cell.bg = Some(bg);
304            ctx.buffer
305                .set(area.x + content_x, area.y + content_y, icon_cell);
306        }
307
308        // Draw message (clipped to available width, wide-char safe)
309        let msg_x = content_x + if self.show_icon { 2 } else { 0 };
310        let max_text_width = toast_width
311            .saturating_sub(if self.show_border { 1 } else { 0 })
312            .saturating_sub(msg_x - x);
313        ctx.draw_text_clipped(
314            area.x + msg_x,
315            area.y + content_y,
316            &self.message,
317            Color::WHITE,
318            max_text_width,
319        );
320    }
321}
322
323impl_styled_view!(Toast);
324impl_props_builders!(Toast);
325
326/// Create a toast notification
327pub fn toast(message: impl Into<String>) -> Toast {
328    Toast::new(message)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::layout::Rect;
335    use crate::render::Buffer;
336
337    #[test]
338    fn test_toast_new() {
339        let t = Toast::new("Test message");
340        assert_eq!(t.message, "Test message");
341        assert_eq!(t.level, ToastLevel::Info);
342    }
343
344    #[test]
345    fn test_toast_levels() {
346        let info = Toast::info("Info");
347        assert_eq!(info.level, ToastLevel::Info);
348
349        let success = Toast::success("Success");
350        assert_eq!(success.level, ToastLevel::Success);
351
352        let warning = Toast::warning("Warning");
353        assert_eq!(warning.level, ToastLevel::Warning);
354
355        let error = Toast::error("Error");
356        assert_eq!(error.level, ToastLevel::Error);
357    }
358
359    #[test]
360    fn test_toast_position() {
361        let t = Toast::new("Test").position(ToastPosition::BottomLeft);
362        assert_eq!(t.position, ToastPosition::BottomLeft);
363    }
364
365    #[test]
366    fn test_toast_level_icon() {
367        assert_eq!(ToastLevel::Info.icon(), 'ℹ');
368        assert_eq!(ToastLevel::Success.icon(), '✓');
369        assert_eq!(ToastLevel::Warning.icon(), '⚠');
370        assert_eq!(ToastLevel::Error.icon(), '✗');
371    }
372
373    #[test]
374    fn test_toast_level_color() {
375        assert_eq!(ToastLevel::Info.color(), Color::CYAN);
376        assert_eq!(ToastLevel::Success.color(), Color::GREEN);
377        assert_eq!(ToastLevel::Warning.color(), Color::YELLOW);
378        assert_eq!(ToastLevel::Error.color(), Color::RED);
379    }
380
381    #[test]
382    fn test_toast_render() {
383        let t = Toast::new("Hello World")
384            .level(ToastLevel::Success)
385            .position(ToastPosition::TopRight);
386
387        let mut buffer = Buffer::new(40, 10);
388        let area = Rect::new(0, 0, 40, 10);
389        let mut ctx = RenderContext::new(&mut buffer, area);
390
391        t.render(&mut ctx);
392    }
393
394    #[test]
395    fn test_toast_no_border() {
396        let t = Toast::new("No border").show_border(false);
397
398        let mut buffer = Buffer::new(30, 5);
399        let area = Rect::new(0, 0, 30, 5);
400        let mut ctx = RenderContext::new(&mut buffer, area);
401
402        t.render(&mut ctx);
403    }
404
405    #[test]
406    fn test_toast_no_icon() {
407        let t = Toast::new("No icon").show_icon(false);
408
409        let mut buffer = Buffer::new(30, 5);
410        let area = Rect::new(0, 0, 30, 5);
411        let mut ctx = RenderContext::new(&mut buffer, area);
412
413        t.render(&mut ctx);
414    }
415
416    #[test]
417    fn test_toast_helper() {
418        let t = toast("Quick toast");
419        assert_eq!(t.message, "Quick toast");
420    }
421
422    #[test]
423    fn test_toast_all_positions() {
424        let positions = [
425            ToastPosition::TopLeft,
426            ToastPosition::TopCenter,
427            ToastPosition::TopRight,
428            ToastPosition::BottomLeft,
429            ToastPosition::BottomCenter,
430            ToastPosition::BottomRight,
431        ];
432
433        for pos in positions {
434            let t = Toast::new("Test").position(pos);
435            let mut buffer = Buffer::new(40, 20);
436            let area = Rect::new(0, 0, 40, 20);
437            let mut ctx = RenderContext::new(&mut buffer, area);
438            t.render(&mut ctx);
439        }
440    }
441}