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 created: 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 {
65            message: message.into(),
66            level,
67            created: Instant::now(),
68            duration,
69        });
70    }
71
72    #[cfg(test)]
73    fn len(&self) -> usize {
74        self.toasts.len()
75    }
76
77    pub fn show(&mut self, ctx: &egui::Context) {
78        self.toasts.retain(|t| t.created.elapsed() < t.duration);
79
80        if self.toasts.is_empty() {
81            return;
82        }
83
84        // Request repaints while toasts are visible so they auto-dismiss.
85        ctx.request_repaint_after(Duration::from_millis(250));
86
87        let screen = ctx.content_rect();
88        let anchor_x = screen.max.x - MARGIN_RIGHT;
89        let mut y = screen.min.y + MARGIN_TOP;
90
91        let mut dismiss: Option<usize> = None;
92
93        for (i, toast) in self.toasts.iter().enumerate() {
94            let area_id = egui::Id::new("toast").with(i);
95
96            egui::Area::new(area_id)
97                .order(egui::Order::Foreground)
98                .fixed_pos(egui::pos2(anchor_x - TOAST_WIDTH, y))
99                .interactable(true)
100                .show(ctx, |ui| {
101                    let frame = egui::Frame::new()
102                        .fill(toast.level.bg_color())
103                        .corner_radius(TOAST_ROUNDING)
104                        .inner_margin(TOAST_PADDING);
105
106                    let response = frame.show(ui, |ui| {
107                        ui.set_max_width(TOAST_WIDTH - TOAST_PADDING * 2.0);
108                        ui.horizontal(|ui| {
109                            ui.add(
110                                egui::Label::new(
111                                    egui::RichText::new(&toast.message)
112                                        .color(ToastLevel::text_color()),
113                                )
114                                .wrap(),
115                            );
116                            ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
117                                if ui
118                                    .add(
119                                        egui::Button::new(
120                                            egui::RichText::new("×")
121                                                .color(ToastLevel::text_color())
122                                                .strong(),
123                                        )
124                                        .frame(false),
125                                    )
126                                    .clicked()
127                                {
128                                    dismiss = Some(i);
129                                }
130                            });
131                        });
132                    });
133
134                    y += response.response.rect.height() + TOAST_SPACING;
135                });
136        }
137
138        if let Some(idx) = dismiss {
139            self.toasts.remove(idx);
140        }
141    }
142}
143
144impl Default for ToastManager {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn toast_push_and_count() {
156        let mut mgr = ToastManager::new();
157        mgr.push(ToastLevel::Info, "hello");
158        mgr.push(ToastLevel::Warning, "warn");
159        mgr.push(ToastLevel::Error, "err");
160        assert_eq!(mgr.len(), 3);
161    }
162
163    #[test]
164    fn toast_auto_expires() {
165        let mut mgr = ToastManager::new();
166        mgr.push_with_duration(ToastLevel::Info, "brief", Duration::from_millis(0));
167        // Duration is zero so the toast is already expired.
168        std::thread::sleep(Duration::from_millis(1));
169        mgr.toasts.retain(|t| t.created.elapsed() < t.duration);
170        assert_eq!(mgr.len(), 0);
171    }
172}