1use egui::{vec2, Align2, Area, Color32, Frame, Id, Margin, Order, Sense, Stroke};
16use egui_components_theme::{mix, Theme};
17
18use crate::common::Variant;
19use crate::icon::{paint_icon, IconKind};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ToastAnchor {
24 TopRight,
25 TopLeft,
26 BottomRight,
27 BottomLeft,
28}
29
30struct Toast {
31 id: Id,
32 variant: Variant,
33 title: Option<String>,
34 message: String,
35 duration: f64,
36 created: Option<f64>,
38}
39
40pub struct Toasts {
42 anchor: ToastAnchor,
43 width: f32,
44 gap: f32,
45 margin: f32,
46 next_id: u64,
47 toasts: Vec<Toast>,
48}
49
50impl Default for Toasts {
51 fn default() -> Self {
52 Self {
53 anchor: ToastAnchor::TopRight,
54 width: 320.0,
55 gap: 8.0,
56 margin: 16.0,
57 next_id: 0,
58 toasts: Vec::new(),
59 }
60 }
61}
62
63impl Toasts {
64 pub fn new() -> Self {
65 Self::default()
66 }
67 pub fn anchor(mut self, anchor: ToastAnchor) -> Self {
68 self.anchor = anchor;
69 self
70 }
71 pub fn width(mut self, w: f32) -> Self {
72 self.width = w;
73 self
74 }
75
76 pub fn add(&mut self, variant: Variant, title: Option<String>, message: impl Into<String>) {
78 let id = Id::new(("toast", self.next_id));
79 self.next_id = self.next_id.wrapping_add(1);
80 self.toasts.push(Toast {
81 id,
82 variant,
83 title,
84 message: message.into(),
85 duration: 4.0,
86 created: None,
87 });
88 }
89
90 pub fn info(&mut self, title: impl Into<String>, message: impl Into<String>) {
91 self.add(Variant::Info, Some(title.into()), message);
92 }
93 pub fn success(&mut self, title: impl Into<String>, message: impl Into<String>) {
94 self.add(Variant::Success, Some(title.into()), message);
95 }
96 pub fn warning(&mut self, title: impl Into<String>, message: impl Into<String>) {
97 self.add(Variant::Warning, Some(title.into()), message);
98 }
99 pub fn error(&mut self, title: impl Into<String>, message: impl Into<String>) {
100 self.add(Variant::Danger, Some(title.into()), message);
101 }
102
103 pub fn show(&mut self, ctx: &egui::Context) {
105 if self.toasts.is_empty() {
106 return;
107 }
108 let now = ctx.input(|i| i.time);
109 let theme = Theme::get(ctx);
110
111 let (pivot, base) = match self.anchor {
112 ToastAnchor::TopRight => (
113 Align2::RIGHT_TOP,
114 ctx.content_rect().right_top() + vec2(-self.margin, self.margin),
115 ),
116 ToastAnchor::TopLeft => (
117 Align2::LEFT_TOP,
118 ctx.content_rect().left_top() + vec2(self.margin, self.margin),
119 ),
120 ToastAnchor::BottomRight => (
121 Align2::RIGHT_BOTTOM,
122 ctx.content_rect().right_bottom() + vec2(-self.margin, -self.margin),
123 ),
124 ToastAnchor::BottomLeft => (
125 Align2::LEFT_BOTTOM,
126 ctx.content_rect().left_bottom() + vec2(self.margin, -self.margin),
127 ),
128 };
129 let stack_down = matches!(self.anchor, ToastAnchor::TopRight | ToastAnchor::TopLeft);
130
131 let mut remove: Vec<Id> = Vec::new();
132 let mut offset_y = 0.0;
133 let mut need_repaint = false;
134
135 for toast in self.toasts.iter_mut() {
136 if toast.created.is_none() {
137 toast.created = Some(now);
138 }
139 let age = now - toast.created.unwrap_or(now);
140 let anchor_pos = base + vec2(0.0, if stack_down { offset_y } else { -offset_y });
141
142 let resp = Area::new(toast.id)
143 .order(Order::Foreground)
144 .fixed_pos(anchor_pos)
145 .pivot(pivot)
146 .show(ctx, |ui| {
147 ui.set_width(self.width);
148 paint_toast(ui, &theme, toast)
149 });
150
151 let card_h = resp.response.rect.height();
152 offset_y += card_h + self.gap;
153
154 let hovered = resp.response.hovered();
155 if resp.inner {
156 remove.push(toast.id);
158 } else if !hovered && age >= toast.duration {
159 remove.push(toast.id);
160 } else if !hovered {
161 need_repaint = true;
162 }
163 }
164
165 self.toasts.retain(|t| !remove.contains(&t.id));
166 if need_repaint {
167 ctx.request_repaint();
168 }
169 }
170}
171
172fn paint_toast(ui: &mut egui::Ui, theme: &Theme, toast: &Toast) -> bool {
174 let c = theme.colors;
175 let (accent, icon) = toast_accent(&c, toast.variant);
176
177 let mut close_clicked = false;
178 Frame::new()
179 .fill(c.popover_background)
180 .stroke(Stroke::new(theme.metrics.border_width, c.border))
181 .corner_radius(theme.corner())
182 .inner_margin(Margin::same(12))
183 .shadow(egui::epaint::Shadow {
184 offset: [0, 4],
185 blur: 18,
186 spread: 0,
187 color: c.overlay,
188 })
189 .show(ui, |ui| {
190 ui.horizontal_top(|ui| {
191 let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
193 paint_icon(ui.painter(), icon, ir, accent, 1.8);
194 ui.add_space(8.0);
195
196 ui.vertical(|ui| {
197 ui.set_width(ui.available_width() - 22.0);
198 if let Some(title) = &toast.title {
199 ui.add(
200 crate::label::Label::new(title.clone())
201 .strong()
202 .size(crate::common::Size::Small),
203 );
204 }
205 ui.add(
206 crate::label::Label::new(toast.message.clone())
207 .muted()
208 .size(crate::common::Size::Small),
209 );
210 });
211
212 let (x_rect, x_resp) =
214 ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
215 let x_color = if x_resp.hovered() {
216 c.foreground
217 } else {
218 c.muted_foreground
219 };
220 paint_icon(ui.painter(), IconKind::Close, x_rect, x_color, 1.4);
221 if x_resp.clicked() {
222 close_clicked = true;
223 }
224 if x_resp.hovered() {
225 ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
226 }
227 });
228 });
229
230 close_clicked
231}
232
233fn toast_accent(c: &egui_components_theme::ThemeColor, v: Variant) -> (Color32, IconKind) {
234 match v {
235 Variant::Success => (c.success_background, IconKind::Check),
236 Variant::Warning => (c.warning_background, IconKind::Warning),
237 Variant::Danger => (c.danger_background, IconKind::Error),
238 Variant::Info => (c.info_background, IconKind::Info),
239 _ => (mix(c.foreground, c.muted_foreground, 0.3), IconKind::Info),
240 }
241}