Skip to main content

dais_ui/widgets/
toast.rs

1//! Lightweight toast notification system.
2//!
3//! Queue messages with a severity level. The `ToastManager` renders them
4//! as a stack of auto-dismissing banners at the top-right of the frame.
5
6use std::time::{Duration, Instant};
7
8const DEFAULT_DURATION: Duration = Duration::from_secs(4);
9const TOAST_WIDTH: f32 = 300.0;
10const TOAST_ROUNDING: f32 = 6.0;
11const TOAST_PADDING: f32 = 10.0;
12const TOAST_SPACING: f32 = 6.0;
13const MARGIN_TOP: f32 = 8.0;
14const MARGIN_RIGHT: f32 = 8.0;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ToastLevel {
18    Info,
19    Warning,
20    Error,
21}
22
23impl ToastLevel {
24    fn bg_color(self) -> egui::Color32 {
25        match self {
26            Self::Info => egui::Color32::from_rgba_unmultiplied(30, 100, 200, 220),
27            Self::Warning => egui::Color32::from_rgba_unmultiplied(200, 160, 20, 220),
28            Self::Error => egui::Color32::from_rgba_unmultiplied(200, 40, 40, 220),
29        }
30    }
31
32    fn text_color() -> egui::Color32 {
33        egui::Color32::WHITE
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct Toast {
39    pub message: String,
40    pub level: ToastLevel,
41    pub shown_at: Option<Instant>,
42    pub duration: Duration,
43}
44
45pub struct ToastManager {
46    toasts: Vec<Toast>,
47}
48
49impl ToastManager {
50    pub fn new() -> Self {
51        Self { toasts: Vec::new() }
52    }
53
54    pub fn push(&mut self, level: ToastLevel, message: impl Into<String>) {
55        self.push_with_duration(level, message, DEFAULT_DURATION);
56    }
57
58    pub fn push_with_duration(
59        &mut self,
60        level: ToastLevel,
61        message: impl Into<String>,
62        duration: Duration,
63    ) {
64        self.toasts.push(Toast { message: message.into(), level, shown_at: None, duration });
65    }
66
67    #[cfg(test)]
68    fn len(&self) -> usize {
69        self.toasts.len()
70    }
71
72    pub fn show(&mut self, ctx: &egui::Context) {
73        let now = Instant::now();
74        for toast in &mut self.toasts {
75            toast.shown_at.get_or_insert(now);
76        }
77        self.toasts.retain(|t| t.shown_at.is_some_and(|shown_at| shown_at.elapsed() < t.duration));
78
79        if self.toasts.is_empty() {
80            return;
81        }
82
83        // Request repaints while toasts are visible so they auto-dismiss.
84        ctx.request_repaint_after(Duration::from_millis(250));
85
86        let screen = ctx.content_rect();
87        let anchor_x = screen.max.x - MARGIN_RIGHT;
88        let mut y = screen.min.y + MARGIN_TOP;
89
90        let mut dismiss: Option<usize> = None;
91
92        for (i, toast) in self.toasts.iter().enumerate() {
93            let area_id = egui::Id::new("toast").with(i);
94
95            egui::Area::new(area_id)
96                .order(egui::Order::Foreground)
97                .fixed_pos(egui::pos2(anchor_x - TOAST_WIDTH, y))
98                .interactable(true)
99                .show(ctx, |ui| {
100                    let frame = egui::Frame::new()
101                        .fill(toast.level.bg_color())
102                        .corner_radius(TOAST_ROUNDING)
103                        .inner_margin(TOAST_PADDING);
104
105                    let response = frame.show(ui, |ui| {
106                        ui.set_max_width(TOAST_WIDTH - TOAST_PADDING * 2.0);
107                        ui.horizontal(|ui| {
108                            ui.add(
109                                egui::Label::new(
110                                    egui::RichText::new(&toast.message)
111                                        .color(ToastLevel::text_color()),
112                                )
113                                .wrap(),
114                            );
115                            ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
116                                if ui
117                                    .add(
118                                        egui::Button::new(
119                                            egui::RichText::new("×")
120                                                .color(ToastLevel::text_color())
121                                                .strong(),
122                                        )
123                                        .frame(false),
124                                    )
125                                    .clicked()
126                                {
127                                    dismiss = Some(i);
128                                }
129                            });
130                        });
131                    });
132
133                    y += response.response.rect.height() + TOAST_SPACING;
134                });
135        }
136
137        if let Some(idx) = dismiss {
138            self.toasts.remove(idx);
139        }
140    }
141}
142
143impl Default for ToastManager {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn toast_push_and_count() {
155        let mut mgr = ToastManager::new();
156        mgr.push(ToastLevel::Info, "hello");
157        mgr.push(ToastLevel::Warning, "warn");
158        mgr.push(ToastLevel::Error, "err");
159        assert_eq!(mgr.len(), 3);
160    }
161
162    #[test]
163    fn toast_auto_expires() {
164        let mut mgr = ToastManager::new();
165        mgr.push_with_duration(ToastLevel::Info, "brief", Duration::from_millis(0));
166        // Duration is zero so the toast is already expired.
167        let ctx = egui::Context::default();
168        mgr.show(&ctx);
169        assert_eq!(mgr.len(), 0);
170    }
171}