egui_notify/
lib.rs

1//! Simple notifications library for egui.
2
3#![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
26/// Main notifications collector.
27/// # Usage
28/// You need to create [`Toasts`] once and call `.show(ctx)` in every frame.
29/// ```
30/// # use std::time::Duration;
31/// use egui_notify::Toasts;
32///
33/// # egui_notify::__run_test_ctx(|ctx| {
34/// let mut t = Toasts::default();
35/// t.info("Hello, World!").duration(Some(Duration::from_secs(5))).closable(true);
36/// // More app code
37/// t.show(ctx);
38/// # });
39/// ```
40pub 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    /// Creates new [`Toasts`] instance.
55    #[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    /// Adds new toast to the collection.
72    /// By default adds toast at the end of the list, can be changed with `self.reverse`.
73    #[allow(clippy::unwrap_used)] // We know that the index is valid
74    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    /// Dismisses the oldest toast
85    pub fn dismiss_oldest_toast(&mut self) {
86        if let Some(toast) = self.toasts.get_mut(0) {
87            toast.dismiss();
88        }
89    }
90
91    /// Dismisses the most recent toast
92    pub fn dismiss_latest_toast(&mut self) {
93        if let Some(toast) = self.toasts.last_mut() {
94            toast.dismiss();
95        }
96    }
97
98    /// Dismisses all toasts
99    pub fn dismiss_all_toasts(&mut self) {
100        for toast in &mut self.toasts {
101            toast.dismiss();
102        }
103    }
104
105    /// Returns the number of toast items.
106    pub fn len(&self) -> usize {
107        self.toasts.len()
108    }
109
110    /// Returns `true` if there are no toast items.
111    pub fn is_empty(&self) -> bool {
112        self.toasts.is_empty()
113    }
114
115    /// Shortcut for adding a toast with info `success`.
116    pub fn success(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
117        self.add(Toast::success(caption))
118    }
119
120    /// Shortcut for adding a toast with info `level`.
121    pub fn info(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
122        self.add(Toast::info(caption))
123    }
124
125    /// Shortcut for adding a toast with warning `level`.
126    pub fn warning(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
127        self.add(Toast::warning(caption))
128    }
129
130    /// Shortcut for adding a toast with error `level`.
131    pub fn error(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
132        self.add(Toast::error(caption))
133    }
134
135    /// Shortcut for adding a toast with no level.
136    pub fn basic(&mut self, caption: impl Into<WidgetText>) -> &mut Toast {
137        self.add(Toast::basic(caption))
138    }
139
140    /// Shortcut for adding a toast with custom `level`.
141    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    /// Should toasts be added in reverse order?
154    pub const fn reverse(mut self, reverse: bool) -> Self {
155        self.reverse = reverse;
156        self
157    }
158
159    /// Where toasts should appear.
160    pub const fn with_anchor(mut self, anchor: Anchor) -> Self {
161        self.anchor = anchor;
162        self
163    }
164
165    /// Sets spacing between adjacent toasts.
166    pub const fn with_spacing(mut self, spacing: f32) -> Self {
167        self.spacing = spacing;
168        self
169    }
170
171    /// Margin or distance from screen to toasts' bounding boxes
172    pub const fn with_margin(mut self, margin: Vec2) -> Self {
173        self.margin = margin;
174        self
175    }
176
177    /// Enables the use of a shadow for toasts.
178    pub const fn with_shadow(mut self, shadow: Shadow) -> Self {
179        self.shadow = Some(shadow);
180        self
181    }
182
183    /// Padding or distance from toasts' bounding boxes to inner contents.
184    pub const fn with_padding(mut self, padding: Vec2) -> Self {
185        self.padding = padding;
186        self
187    }
188
189    /// Changes the default font used for all toasts.
190    pub fn with_default_font(mut self, font: FontId) -> Self {
191        self.font = Some(font);
192        self
193    }
194}
195
196impl Toasts {
197    /// Displays toast queue
198    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        // `held` used to prevent sticky removal
214        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            // Start disappearing expired toasts
223            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                // 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_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            // Create toast icon
260            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            // Create closing cross
289            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            // Required due to positioning of the next toast
331            pos.x -= anim_offset * anchor.anim_side();
332
333            // Draw shadow
334            if let Some(shadow) = self.shadow {
335                let s = shadow.as_shape(rect, rounding);
336                p.add(s);
337            }
338
339            // Draw background
340            p.rect_filled(rect, rounding, visuals.bg_fill);
341
342            // Paint icon
343            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            // Paint caption
356            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            // Paint cross
375            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            // Draw duration
396            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            // Animations
413            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            // Remove disappeared toasts
431            !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}