Skip to main content

bevy_egui_notify/
lib.rs

1//! egui-notify
2//! Simple notifications library for egui.
3
4#![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
29/// Main notifications collector.
30/// # Usage
31/// You need to create [`Toasts`] once and call `.show(ctx)` in every frame.
32/// ```
33/// # use std::time::Duration;
34/// use egui_notify::Toasts;
35///
36/// # egui_notify::__run_test_ctx(|ctx| {
37/// let mut t = Toasts::default();
38/// t.info("Hello, World!").duration(Some(Duration::from_secs(5))).closable(true);
39/// // More app code
40/// t.show(ctx);
41/// # });
42/// ```
43pub 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    /// Creates new [`Toasts`] instance.
58    #[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    /// Adds new toast to the collection.
75    /// By default adds toast at the end of the list, can be changed with `self.reverse`.
76    #[allow(clippy::unwrap_used)] // We know that the index is valid
77    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    /// Dismisses the oldest toast
88    pub fn dismiss_oldest_toast(&mut self) {
89        if let Some(toast) = self.toasts.get_mut(0) {
90            toast.dismiss();
91        }
92    }
93
94    /// Dismisses the most recent toast
95    pub fn dismiss_latest_toast(&mut self) {
96        if let Some(toast) = self.toasts.last_mut() {
97            toast.dismiss();
98        }
99    }
100
101    /// Dismisses all toasts
102    pub fn dismiss_all_toasts(&mut self) {
103        for toast in &mut self.toasts {
104            toast.dismiss();
105        }
106    }
107
108    /// Shortcut for adding a toast with info `success`.
109    pub fn success(&mut self, caption: impl Into<String>) -> &mut Toast {
110        self.add(Toast::success(caption))
111    }
112
113    /// Shortcut for adding a toast with info `level`.
114    pub fn info(&mut self, caption: impl Into<String>) -> &mut Toast {
115        self.add(Toast::info(caption))
116    }
117
118    /// Shortcut for adding a toast with warning `level`.
119    pub fn warning(&mut self, caption: impl Into<String>) -> &mut Toast {
120        self.add(Toast::warning(caption))
121    }
122
123    /// Shortcut for adding a toast with error `level`.
124    pub fn error(&mut self, caption: impl Into<String>) -> &mut Toast {
125        self.add(Toast::error(caption))
126    }
127
128    /// Shortcut for adding a toast with no level.
129    pub fn basic(&mut self, caption: impl Into<String>) -> &mut Toast {
130        self.add(Toast::basic(caption))
131    }
132
133    /// Shortcut for adding a toast with custom `level`.
134    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    /// Should toasts be added in reverse order?
147    pub const fn reverse(mut self, reverse: bool) -> Self {
148        self.reverse = reverse;
149        self
150    }
151
152    /// Where toasts should appear.
153    pub const fn with_anchor(mut self, anchor: Anchor) -> Self {
154        self.anchor = anchor;
155        self
156    }
157
158    /// Sets spacing between adjacent toasts.
159    pub const fn with_spacing(mut self, spacing: f32) -> Self {
160        self.spacing = spacing;
161        self
162    }
163
164    /// Margin or distance from screen to toasts' bounding boxes
165    pub const fn with_margin(mut self, margin: Vec2) -> Self {
166        self.margin = margin;
167        self
168    }
169
170    /// Enables the use of a shadow for toasts.
171    pub const fn with_shadow(mut self, shadow: Shadow) -> Self {
172        self.shadow = Some(shadow);
173        self
174    }
175
176    /// Padding or distance from toasts' bounding boxes to inner contents.
177    pub const fn with_padding(mut self, padding: Vec2) -> Self {
178        self.padding = padding;
179        self
180    }
181
182    /// Changes the default font used for all toasts.
183    pub fn with_default_font(mut self, font: FontId) -> Self {
184        self.font = Some(font);
185        self
186    }
187}
188
189impl Toasts {
190    /// Displays toast queue
191    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        // Remove disappeared toasts
209        toasts.retain(|t| !t.state.disappeared());
210
211        // Start disappearing expired toasts
212        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        // `held` used to prevent sticky removal
221        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                // Check if we hover over the toast and if true don't decrease the duration
235                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            // Create toast label
253            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            // Create toast icon
270            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            // Create closing cross
296            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            // Required due to positioning of the next toast
338            pos.x -= anim_offset * anchor.anim_side();
339
340            // Draw shadow
341            if let Some(shadow) = self.shadow {
342                let s = shadow.as_shape(rect, rounding);
343                p.add(s);
344            }
345
346            // Draw background
347            p.rect_filled(rect, rounding, visuals.bg_fill);
348
349            // Paint icon
350            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            // Paint caption
359            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            // Paint cross
374            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            // Draw duration
395            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            // Animations
412            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}