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