1#![warn(missing_docs)]
5
6use bevy_egui::egui;
7mod bevy_plugin;
8pub use bevy_plugin::*;
9
10mod toast;
11pub use toast::*;
12mod anchor;
13pub use anchor::*;
14
15#[doc(hidden)]
16pub use egui::__run_test_ctx;
17use egui::{
18 vec2, Color32, Context, CornerRadius, FontId, Id, LayerId, Order, Rect, Shadow, Stroke, Vec2,
19};
20
21pub(crate) const TOAST_WIDTH: f32 = 180.;
22pub(crate) const TOAST_HEIGHT: f32 = 34.;
23
24const ERROR_COLOR: Color32 = Color32::from_rgb(200, 90, 90);
25const INFO_COLOR: Color32 = Color32::from_rgb(150, 200, 210);
26const WARNING_COLOR: Color32 = Color32::from_rgb(230, 220, 140);
27const SUCCESS_COLOR: Color32 = Color32::from_rgb(140, 230, 140);
28
29pub struct Toasts {
44 toasts: Vec<Toast>,
45 anchor: Anchor,
46 margin: Vec2,
47 spacing: f32,
48 padding: Vec2,
49 reverse: bool,
50 speed: f32,
51 font: Option<FontId>,
52 shadow: Option<Shadow>,
53 held: bool,
54}
55
56impl Toasts {
57 #[must_use]
59 pub const fn new() -> Self {
60 Self {
61 anchor: Anchor::TopRight,
62 margin: vec2(8., 8.),
63 toasts: vec![],
64 spacing: 8.,
65 padding: vec2(10., 10.),
66 held: false,
67 speed: 4.,
68 reverse: false,
69 font: None,
70 shadow: None,
71 }
72 }
73
74 #[allow(clippy::unwrap_used)] pub fn add(&mut self, toast: Toast) -> &mut Toast {
78 if self.reverse {
79 self.toasts.insert(0, toast);
80 return self.toasts.get_mut(0).unwrap();
81 }
82 self.toasts.push(toast);
83 let l = self.toasts.len() - 1;
84 self.toasts.get_mut(l).unwrap()
85 }
86
87 pub fn dismiss_oldest_toast(&mut self) {
89 if let Some(toast) = self.toasts.get_mut(0) {
90 toast.dismiss();
91 }
92 }
93
94 pub fn dismiss_latest_toast(&mut self) {
96 if let Some(toast) = self.toasts.last_mut() {
97 toast.dismiss();
98 }
99 }
100
101 pub fn dismiss_all_toasts(&mut self) {
103 for toast in &mut self.toasts {
104 toast.dismiss();
105 }
106 }
107
108 pub fn success(&mut self, caption: impl Into<String>) -> &mut Toast {
110 self.add(Toast::success(caption))
111 }
112
113 pub fn info(&mut self, caption: impl Into<String>) -> &mut Toast {
115 self.add(Toast::info(caption))
116 }
117
118 pub fn warning(&mut self, caption: impl Into<String>) -> &mut Toast {
120 self.add(Toast::warning(caption))
121 }
122
123 pub fn error(&mut self, caption: impl Into<String>) -> &mut Toast {
125 self.add(Toast::error(caption))
126 }
127
128 pub fn basic(&mut self, caption: impl Into<String>) -> &mut Toast {
130 self.add(Toast::basic(caption))
131 }
132
133 pub fn custom(
135 &mut self,
136 caption: impl Into<String>,
137 level_string: String,
138 level_color: egui::Color32,
139 ) -> &mut Toast {
140 self.add(Toast::custom(
141 caption,
142 ToastLevel::Custom(level_string, level_color),
143 ))
144 }
145
146 pub const fn reverse(mut self, reverse: bool) -> Self {
148 self.reverse = reverse;
149 self
150 }
151
152 pub const fn with_anchor(mut self, anchor: Anchor) -> Self {
154 self.anchor = anchor;
155 self
156 }
157
158 pub const fn with_spacing(mut self, spacing: f32) -> Self {
160 self.spacing = spacing;
161 self
162 }
163
164 pub const fn with_margin(mut self, margin: Vec2) -> Self {
166 self.margin = margin;
167 self
168 }
169
170 pub const fn with_shadow(mut self, shadow: Shadow) -> Self {
172 self.shadow = Some(shadow);
173 self
174 }
175
176 pub const fn with_padding(mut self, padding: Vec2) -> Self {
178 self.padding = padding;
179 self
180 }
181
182 pub fn with_default_font(mut self, font: FontId) -> Self {
184 self.font = Some(font);
185 self
186 }
187}
188
189impl Toasts {
190 pub fn show(&mut self, ctx: &Context) {
192 let Self {
193 anchor,
194 margin,
195 spacing,
196 padding,
197 toasts,
198 held,
199 speed,
200 ..
201 } = self;
202
203 let mut pos = anchor.screen_corner(ctx.input(|i| i.screen_rect.max), *margin);
204 let p = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("toasts")));
205
206 let mut dismiss = None;
207
208 toasts.retain(|t| !t.state.disappeared());
210
211 for t in toasts.iter_mut() {
213 if let Some((_initial_d, current_d)) = t.duration {
214 if current_d <= 0. {
215 t.state = ToastState::Disappear;
216 }
217 }
218 }
219
220 if ctx.input(|i| i.pointer.primary_released()) {
222 *held = false;
223 }
224
225 let visuals = ctx.style().visuals.widgets.noninteractive;
226 let mut update = false;
227
228 for (i, toast) in toasts.iter_mut().enumerate() {
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_font = toast
245 .font
246 .as_ref()
247 .or(self.font.as_ref())
248 .or(ctx.style().override_font_id.as_ref())
249 .cloned()
250 .unwrap_or_else(|| FontId::proportional(16.));
251
252 let caption_galley = ctx.fonts(|f| {
254 f.layout(
255 toast.caption.clone(),
256 caption_font,
257 visuals.fg_stroke.color,
258 f32::INFINITY,
259 )
260 });
261
262 let (caption_width, caption_height) =
263 (caption_galley.rect.width(), caption_galley.rect.height());
264
265 let line_count = toast.caption.chars().filter(|c| *c == '\n').count() + 1;
266 let icon_width = caption_height / line_count as f32;
267 let rounding = CornerRadius::same(4);
268
269 let icon_font = FontId::proportional(icon_width);
271 let icon_galley = match &toast.level {
272 ToastLevel::Info => {
273 Some(ctx.fonts(|f| f.layout("ℹ".into(), icon_font, INFO_COLOR, f32::INFINITY)))
274 }
275 ToastLevel::Warning => Some(
276 ctx.fonts(|f| f.layout("⚠".into(), icon_font, WARNING_COLOR, f32::INFINITY)),
277 ),
278 ToastLevel::Error => Some(
279 ctx.fonts(|f| f.layout("!".into(), icon_font, ERROR_COLOR, f32::INFINITY)),
280 ),
281 ToastLevel::Success => Some(
282 ctx.fonts(|f| f.layout("✅".into(), icon_font, SUCCESS_COLOR, f32::INFINITY)),
283 ),
284 ToastLevel::Custom(s, c) => {
285 Some(ctx.fonts(|f| f.layout(s.clone(), icon_font, *c, f32::INFINITY)))
286 }
287 ToastLevel::None => None,
288 };
289
290 let (action_width, action_height) =
291 icon_galley.as_ref().map_or((0., 0.), |icon_galley| {
292 (icon_galley.rect.width(), icon_galley.rect.height())
293 });
294
295 let cross_galley = if toast.closable {
297 let cross_fid = FontId::proportional(icon_width);
298 let cross_galley = ctx.fonts(|f| {
299 f.layout(
300 "❌".into(),
301 cross_fid,
302 visuals.fg_stroke.color,
303 f32::INFINITY,
304 )
305 });
306 Some(cross_galley)
307 } else {
308 None
309 };
310
311 let (cross_width, cross_height) =
312 cross_galley.as_ref().map_or((0., 0.), |cross_galley| {
313 (cross_galley.rect.width(), cross_galley.rect.height())
314 });
315
316 let icon_x_padding = (0., padding.x);
317 let cross_x_padding = (padding.x, 0.);
318
319 let icon_width_padded = if icon_width == 0. {
320 0.
321 } else {
322 icon_width + icon_x_padding.0 + icon_x_padding.1
323 };
324 let cross_width_padded = if cross_width == 0. {
325 0.
326 } else {
327 cross_width + cross_x_padding.0 + cross_x_padding.1
328 };
329
330 toast.width = padding
331 .x
332 .mul_add(2., icon_width_padded + caption_width + cross_width_padded);
333 toast.height = padding
334 .y
335 .mul_add(2., action_height.max(caption_height).max(cross_height));
336
337 pos.x -= anim_offset * anchor.anim_side();
339
340 if let Some(shadow) = self.shadow {
342 let s = shadow.as_shape(rect, rounding);
343 p.add(s);
344 }
345
346 p.rect_filled(rect, rounding, visuals.bg_fill);
348
349 if let Some((icon_galley, true)) =
351 icon_galley.zip(Some(toast.level != ToastLevel::None))
352 {
353 let oy = toast.height / 2. - action_height / 2.;
354 let ox = padding.x + icon_x_padding.0;
355 p.galley(rect.min + vec2(ox, oy), icon_galley, Color32::BLACK);
356 }
357
358 let oy = toast.height / 2. - caption_height / 2.;
360 let o_from_icon = if action_width == 0. {
361 0.
362 } else {
363 action_width + icon_x_padding.1
364 };
365 let o_from_cross = if cross_width == 0. {
366 0.
367 } else {
368 cross_width + cross_x_padding.0
369 };
370 let ox = (toast.width / 2. - caption_width / 2.) + o_from_icon / 2. - o_from_cross / 2.;
371 p.galley(rect.min + vec2(ox, oy), caption_galley, Color32::BLACK);
372
373 if let Some(cross_galley) = cross_galley {
375 let cross_rect = cross_galley.rect;
376 let oy = toast.height / 2. - cross_height / 2.;
377 let ox = toast.width - cross_width - cross_x_padding.1 - padding.x;
378 let cross_pos = rect.min + vec2(ox, oy);
379 p.galley(cross_pos, cross_galley, Color32::BLACK);
380
381 let screen_cross = Rect {
382 max: cross_pos + cross_rect.max.to_vec2(),
383 min: cross_pos,
384 };
385
386 if let Some(pos) = ctx.input(|i| i.pointer.press_origin()) {
387 if screen_cross.contains(pos) && !*held {
388 dismiss = Some(i);
389 *held = true;
390 }
391 }
392 }
393
394 if toast.show_progress_bar {
396 if let Some((initial, current)) = toast.duration {
397 if !toast.state.disappearing() {
398 p.line_segment(
399 [
400 rect.min + vec2(0., toast.height),
401 rect.max - vec2((1. - (current / initial)) * toast.width, 0.),
402 ],
403 Stroke::new(4., visuals.fg_stroke.color),
404 );
405 }
406 }
407 }
408
409 toast.adjust_next_pos(&mut pos, *anchor, *spacing);
410
411 if toast.state.appearing() {
413 update = true;
414 toast.value += ctx.input(|i| i.stable_dt) * (*speed);
415
416 if toast.value >= 1. {
417 toast.value = 1.;
418 toast.state = ToastState::Idle;
419 }
420 } else if toast.state.disappearing() {
421 update = true;
422 toast.value -= ctx.input(|i| i.stable_dt) * (*speed);
423
424 if toast.value <= 0. {
425 toast.state = ToastState::Disappeared;
426 }
427 }
428 }
429
430 if update {
431 ctx.request_repaint();
432 }
433
434 if let Some(i) = dismiss {
435 self.toasts[i].dismiss();
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}