1#![warn(missing_docs)]
4
5mod toast;
6pub use toast::*;
7mod anchor;
8pub use anchor::*;
9
10#[doc(hidden)]
11pub use egui::__run_test_ctx;
12use egui::text::TextWrapping;
13use egui::{
14 vec2, Align, Color32, Context, CornerRadius, FontId, FontSelection, Id, LayerId, Order, Rect,
15 Shadow, Stroke, TextWrapMode, Vec2, WidgetText,
16};
17
18pub(crate) const TOAST_WIDTH: f32 = 180.;
19pub(crate) const TOAST_HEIGHT: f32 = 34.;
20
21const ERROR_COLOR: Color32 = Color32::from_rgb(200, 90, 90);
22const INFO_COLOR: Color32 = Color32::from_rgb(150, 200, 210);
23const WARNING_COLOR: Color32 = Color32::from_rgb(230, 220, 140);
24const SUCCESS_COLOR: Color32 = Color32::from_rgb(140, 230, 140);
25
26pub struct Toasts {
41 toasts: Vec<Toast>,
42 anchor: Anchor,
43 margin: Vec2,
44 spacing: f32,
45 padding: Vec2,
46 reverse: bool,
47 speed: f32,
48 font: Option<FontId>,
49 shadow: Option<Shadow>,
50 held: bool,
51}
52
53impl Toasts {
54 #[must_use]
56 pub const fn new() -> Self {
57 Self {
58 anchor: Anchor::TopRight,
59 margin: vec2(8., 8.),
60 toasts: vec![],
61 spacing: 8.,
62 padding: vec2(10., 10.),
63 held: false,
64 speed: 4.,
65 reverse: false,
66 font: None,
67 shadow: None,
68 }
69 }
70
71 #[allow(clippy::unwrap_used)] pub fn add(&mut self, toast: Toast) -> &mut Toast {
75 if self.reverse {
76 self.toasts.insert(0, toast);
77 return self.toasts.get_mut(0).unwrap();
78 }
79 self.toasts.push(toast);
80 let l = self.toasts.len() - 1;
81 self.toasts.get_mut(l).unwrap()
82 }
83
84 pub fn dismiss_oldest_toast(&mut self) {
86 if let Some(toast) = self.toasts.get_mut(0) {
87 toast.dismiss();
88 }
89 }
90
91 pub fn dismiss_latest_toast(&mut self) {
93 if let Some(toast) = self.toasts.last_mut() {
94 toast.dismiss();
95 }
96 }
97
98 pub fn dismiss_all_toasts(&mut self) {
100 for toast in &mut self.toasts {
101 toast.dismiss();
102 }
103 }
104
105 pub fn len(&self) -> usize {
107 self.toasts.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
112 self.toasts.is_empty()
113 }
114
115 pub fn success(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
117 self.add(Toast::success(caption))
118 }
119
120 pub fn info(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
122 self.add(Toast::info(caption))
123 }
124
125 pub fn warning(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
127 self.add(Toast::warning(caption))
128 }
129
130 pub fn error(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
132 self.add(Toast::error(caption))
133 }
134
135 pub fn basic(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
137 self.add(Toast::basic(caption))
138 }
139
140 pub fn custom(
142 &mut self,
143 caption: impl Into<WidgetText>,
144 level_string: String,
145 level_color: egui::Color32,
146 ) -> &mut Toast {
147 self.add(Toast::custom(
148 caption,
149 ToastLevel::Custom(level_string, level_color),
150 ))
151 }
152
153 pub const fn reverse(mut self, reverse: bool) -> Self {
155 self.reverse = reverse;
156 self
157 }
158
159 pub const fn with_anchor(mut self, anchor: Anchor) -> Self {
161 self.anchor = anchor;
162 self
163 }
164
165 pub const fn with_spacing(mut self, spacing: f32) -> Self {
167 self.spacing = spacing;
168 self
169 }
170
171 pub const fn with_margin(mut self, margin: Vec2) -> Self {
173 self.margin = margin;
174 self
175 }
176
177 pub const fn with_shadow(mut self, shadow: Shadow) -> Self {
179 self.shadow = Some(shadow);
180 self
181 }
182
183 pub const fn with_padding(mut self, padding: Vec2) -> Self {
185 self.padding = padding;
186 self
187 }
188
189 pub fn with_default_font(mut self, font: FontId) -> Self {
191 self.font = Some(font);
192 self
193 }
194}
195
196impl Toasts {
197 pub fn show(&mut self, ctx: &Context) {
199 let Self {
200 anchor,
201 margin,
202 spacing,
203 padding,
204 toasts,
205 held,
206 speed,
207 ..
208 } = self;
209
210 let mut pos = anchor.screen_corner(ctx.input(|i| i.content_rect().max), *margin);
211 let p = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("toasts")));
212
213 if ctx.input(|i| i.pointer.primary_released()) {
215 *held = false;
216 }
217
218 let visuals = ctx.style().visuals.widgets.noninteractive;
219 let mut update = false;
220
221 toasts.retain_mut(|toast| {
222 if let Some((_initial_d, current_d)) = toast.duration {
224 if current_d <= 0. {
225 toast.state = ToastState::Disappear;
226 }
227 }
228
229 let anim_offset = toast.width * (1. - ease_in_cubic(toast.value));
230 pos.x += anim_offset * anchor.anim_side();
231 let rect = toast.calc_anchored_rect(pos, *anchor);
232
233 if let Some((_, d)) = toast.duration.as_mut() {
234 let hover_pos = ctx.input(|i| i.pointer.hover_pos());
236 let is_outside_rect = hover_pos.is_none_or(|pos| !rect.contains(pos));
237
238 if is_outside_rect && toast.state.idling() {
239 *d -= ctx.input(|i| i.stable_dt);
240 update = true;
241 }
242 }
243
244 let caption_galley = toast.caption.clone().into_galley_impl(
245 ctx,
246 ctx.style().as_ref(),
247 TextWrapping::from_wrap_mode_and_width(TextWrapMode::Extend, f32::INFINITY),
248 FontSelection::Default,
249 Align::LEFT,
250 );
251
252 let (caption_width, caption_height) =
253 (caption_galley.rect.width(), caption_galley.rect.height());
254
255 let line_count = toast.caption.text().chars().filter(|c| *c == '\n').count() + 1;
256 let icon_width = caption_height / line_count as f32;
257 let rounding = CornerRadius::same(4);
258
259 let icon_font = FontId::proportional(icon_width);
261 let icon_galley =
262 match &toast.level {
263 ToastLevel::Info => {
264 Some(ctx.fonts_mut(|f| {
265 f.layout("ℹ".into(), icon_font, INFO_COLOR, f32::INFINITY)
266 }))
267 }
268 ToastLevel::Warning => Some(ctx.fonts_mut(|f| {
269 f.layout("⚠".into(), icon_font, WARNING_COLOR, f32::INFINITY)
270 })),
271 ToastLevel::Error => Some(ctx.fonts_mut(|f| {
272 f.layout("!".into(), icon_font, ERROR_COLOR, f32::INFINITY)
273 })),
274 ToastLevel::Success => Some(ctx.fonts_mut(|f| {
275 f.layout("✅".into(), icon_font, SUCCESS_COLOR, f32::INFINITY)
276 })),
277 ToastLevel::Custom(s, c) => {
278 Some(ctx.fonts_mut(|f| f.layout(s.clone(), icon_font, *c, f32::INFINITY)))
279 }
280 ToastLevel::None => None,
281 };
282
283 let (action_width, action_height) =
284 icon_galley.as_ref().map_or((0., 0.), |icon_galley| {
285 (icon_galley.rect.width(), icon_galley.rect.height())
286 });
287
288 let cross_galley = if toast.closable {
290 let cross_fid = FontId::proportional(icon_width);
291 let cross_galley = ctx.fonts_mut(|f| {
292 f.layout(
293 "❌".into(),
294 cross_fid,
295 visuals.fg_stroke.color,
296 f32::INFINITY,
297 )
298 });
299 Some(cross_galley)
300 } else {
301 None
302 };
303
304 let (cross_width, cross_height) =
305 cross_galley.as_ref().map_or((0., 0.), |cross_galley| {
306 (cross_galley.rect.width(), cross_galley.rect.height())
307 });
308
309 let icon_x_padding = (0., padding.x);
310 let cross_x_padding = (padding.x, 0.);
311
312 let icon_width_padded = if icon_width == 0. {
313 0.
314 } else {
315 icon_width + icon_x_padding.0 + icon_x_padding.1
316 };
317 let cross_width_padded = if cross_width == 0. {
318 0.
319 } else {
320 cross_width + cross_x_padding.0 + cross_x_padding.1
321 };
322
323 toast.width = padding
324 .x
325 .mul_add(2., icon_width_padded + caption_width + cross_width_padded);
326 toast.height = padding
327 .y
328 .mul_add(2., action_height.max(caption_height).max(cross_height));
329
330 pos.x -= anim_offset * anchor.anim_side();
332
333 if let Some(shadow) = self.shadow {
335 let s = shadow.as_shape(rect, rounding);
336 p.add(s);
337 }
338
339 p.rect_filled(rect, rounding, visuals.bg_fill);
341
342 if let Some((icon_galley, true)) =
344 icon_galley.zip(Some(toast.level != ToastLevel::None))
345 {
346 let oy = toast.height / 2. - action_height / 2.;
347 let ox = padding.x + icon_x_padding.0;
348 p.galley(
349 rect.min + vec2(ox, oy),
350 icon_galley,
351 visuals.fg_stroke.color,
352 );
353 }
354
355 let oy = toast.height / 2. - caption_height / 2.;
357 let o_from_icon = if action_width == 0. {
358 0.
359 } else {
360 action_width + icon_x_padding.1
361 };
362 let o_from_cross = if cross_width == 0. {
363 0.
364 } else {
365 cross_width + cross_x_padding.0
366 };
367 let ox = (toast.width / 2. - caption_width / 2.) + o_from_icon / 2. - o_from_cross / 2.;
368 p.galley(
369 rect.min + vec2(ox, oy),
370 caption_galley,
371 visuals.fg_stroke.color,
372 );
373
374 if let Some(cross_galley) = cross_galley {
376 let cross_rect = cross_galley.rect;
377 let oy = toast.height / 2. - cross_height / 2.;
378 let ox = toast.width - cross_width - cross_x_padding.1 - padding.x;
379 let cross_pos = rect.min + vec2(ox, oy);
380 p.galley(cross_pos, cross_galley, Color32::BLACK);
381
382 let screen_cross = Rect {
383 max: cross_pos + cross_rect.max.to_vec2(),
384 min: cross_pos,
385 };
386
387 if let Some(pos) = ctx.input(|i| i.pointer.press_origin()) {
388 if screen_cross.contains(pos) && !*held {
389 toast.dismiss();
390 *held = true;
391 }
392 }
393 }
394
395 if toast.show_progress_bar {
397 if let Some((initial, current)) = toast.duration {
398 if !toast.state.disappearing() {
399 p.line_segment(
400 [
401 rect.min + vec2(0., toast.height),
402 rect.max - vec2((1. - (current / initial)) * toast.width, 0.),
403 ],
404 Stroke::new(4., visuals.fg_stroke.color),
405 );
406 }
407 }
408 }
409
410 toast.adjust_next_pos(&mut pos, *anchor, *spacing);
411
412 if toast.state.appearing() {
414 update = true;
415 toast.value += ctx.input(|i| i.stable_dt) * (*speed);
416
417 if toast.value >= 1. {
418 toast.value = 1.;
419 toast.state = ToastState::Idle;
420 }
421 } else if toast.state.disappearing() {
422 update = true;
423 toast.value -= ctx.input(|i| i.stable_dt) * (*speed);
424
425 if toast.value <= 0. {
426 toast.state = ToastState::Disappeared;
427 }
428 }
429
430 !toast.state.disappeared()
432 });
433
434 if update {
435 ctx.request_repaint();
436 }
437 }
438}
439
440impl Default for Toasts {
441 fn default() -> Self {
442 Self::new()
443 }
444}
445
446fn ease_in_cubic(x: f32) -> f32 {
447 1. - (1. - x).powi(3)
448}