1use 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 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 let ctx = egui::Context::default();
168 mgr.show(&ctx);
169 assert_eq!(mgr.len(), 0);
170 }
171}