egui_toast/
lib.rs

1//! This crate provides a convenient interface for showing toast notifications with
2//! the [egui](https://github.com/emilk/egui) library.
3//!
4//! For a complete example, see <https://github.com/urholaukkarinen/egui-toast/tree/main/demo>.
5//!
6//! # Usage
7//!
8//! To get started, create a `Toasts` instance in your rendering code and specify the anchor position and
9//! direction for the notifications. Toast notifications will show up starting from the specified
10//! anchor position and stack up in the specified direction.
11//! ```
12//! # use std::time::Duration;
13//! use egui::Align2;
14//! # use egui_toast::{Toasts, ToastKind, ToastOptions, Toast};
15//! # egui_toast::__run_test_ui(|ui, ctx| {
16//! let mut toasts = Toasts::new()
17//!     .anchor(Align2::LEFT_TOP, (10.0, 10.0))
18//!     .direction(egui::Direction::TopDown);
19//!
20//! toasts.add(Toast {
21//!     text: "Hello, World".into(),
22//!     kind: ToastKind::Info,
23//!     options: ToastOptions::default()
24//!         .duration_in_seconds(3.0)
25//!         .show_progress(true)
26//!         .show_icon(true),
27//!     ..Default::default()
28//! });
29//!
30//! // Show all toasts
31//! toasts.show(ctx);
32//! # })
33//! ```
34//!
35//! ## Layer Ordering
36//!
37//! By default, toasts are shown in the [`egui::Order::Foreground`] layer. To ensure toasts
38//! appear above modals or other foreground elements, you can use [`egui::Order::Tooltip`]:
39//!
40//! ```
41//! # use egui_toast::Toasts;
42//! # use egui::{Align2, Order};
43//! # egui_toast::__run_test_ui(|ui, ctx| {
44//! let mut toasts = Toasts::new()
45//!     .anchor(Align2::RIGHT_TOP, (-10.0, 10.0))
46//!     .order(Order::Tooltip); // Ensures toasts appear above modals
47//! # })
48//! ```
49//!
50//! Look of the notifications can be fully customized by specifying a custom rendering function for a specific toast kind
51//! with [`Toasts::custom_contents`]. [`ToastKind::Custom`] can be used if the default kinds are not sufficient.
52//!
53//! ```
54//! # use std::time::Duration;
55//! # use std::sync::Arc;
56//! # use egui_toast::{Toast, ToastKind, ToastOptions, Toasts};
57//! # egui_toast::__run_test_ui(|ui, ctx| {
58//! const MY_CUSTOM_TOAST: u32 = 0;
59//!
60//! fn custom_toast_contents(ui: &mut egui::Ui, toast: &mut Toast) -> egui::Response {
61//!     egui::Frame::window(ui.style()).show(ui, |ui| {
62//!         ui.label(toast.text.clone());
63//!     }).response
64//! }
65//!
66//! let mut toasts = Toasts::new()
67//!     .custom_contents(MY_CUSTOM_TOAST, custom_toast_contents);
68//!
69//! // Add a custom toast that never expires
70//! toasts.add(Toast {
71//!     text: "Hello, World".into(),
72//!     kind: ToastKind::Custom(MY_CUSTOM_TOAST),
73//!     options: ToastOptions::default(),
74//!     ..Default::default()
75//! });
76//!
77//! # })
78//! ```
79//!
80#![deny(clippy::all)]
81
82mod toast;
83pub use toast::*;
84
85use std::collections::HashMap;
86use std::sync::Arc;
87use std::time::Duration;
88
89use egui::epaint::RectShape;
90use egui::{
91    Align2, Area, Context, CornerRadius, Direction, Frame, Id, Order, Pos2, Response, Shape,
92    Stroke, StrokeKind, Ui,
93};
94
95pub type ToastContents = dyn Fn(&mut Ui, &mut Toast) -> Response + Send + Sync;
96
97pub struct Toasts {
98    id: Id,
99    align: Align2,
100    offset: Pos2,
101    direction: Direction,
102    order: Order,
103    custom_toast_contents: HashMap<ToastKind, Arc<ToastContents>>,
104    /// Toasts added since the last draw call. These are moved to the
105    /// egui context's memory, so you are free to recreate the [`Toasts`] instance every frame.
106    added_toasts: Vec<Toast>,
107}
108
109impl Default for Toasts {
110    fn default() -> Self {
111        Self {
112            id: Id::new("__toasts"),
113            align: Align2::LEFT_TOP,
114            offset: Pos2::new(10.0, 10.0),
115            direction: Direction::TopDown,
116            order: Order::Foreground,
117            custom_toast_contents: HashMap::new(),
118            added_toasts: Vec::new(),
119        }
120    }
121}
122
123impl Toasts {
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Create a new [`Toasts`] instance with a custom id
129    ///
130    /// This can be useful if you want to have multiple toast groups
131    /// in the same UI.
132    pub fn with_id(id: Id) -> Self {
133        Self {
134            id,
135            ..Default::default()
136        }
137    }
138
139    /// Set the layer order for the toasts.
140    ///
141    /// Default is [`Order::Foreground`]. Use [`Order::Tooltip`] to ensure 
142    /// toasts appear above modals and other foreground elements.
143    pub fn order(mut self, order: Order) -> Self {
144        self.order = order;
145        self
146    }
147
148    /// Position where the toasts show up.
149    ///
150    /// The toasts will start from this position and stack up
151    /// in the direction specified with [`Self::direction`].
152    pub fn position(mut self, position: impl Into<Pos2>) -> Self {
153        self.offset = position.into();
154        self
155    }
156
157    /// Anchor for the toasts.
158    ///
159    /// For instance, if you set this to (10.0, 10.0) and [`Align2::LEFT_TOP`],
160    /// then (10.0, 10.0) will be the top-left corner of the first toast.
161    pub fn anchor(mut self, anchor: Align2, offset: impl Into<Pos2>) -> Self {
162        self.align = anchor;
163        self.offset = offset.into();
164        self
165    }
166
167    /// Direction where the toasts stack up
168    pub fn direction(mut self, direction: impl Into<Direction>) -> Self {
169        self.direction = direction.into();
170        self
171    }
172
173    /// Can be used to specify a custom rendering function for toasts for given kind
174    pub fn custom_contents(
175        mut self,
176        kind: impl Into<ToastKind>,
177        add_contents: impl Fn(&mut Ui, &mut Toast) -> Response + Send + Sync + 'static,
178    ) -> Self {
179        self.custom_toast_contents
180            .insert(kind.into(), Arc::new(add_contents));
181        self
182    }
183
184    /// Add a new toast
185    pub fn add(&mut self, toast: Toast) -> &mut Self {
186        self.added_toasts.push(toast);
187        self
188    }
189
190    /// Show and update all toasts
191    pub fn show(&mut self, ctx: &Context) {
192        let Self {
193            id,
194            align,
195            mut offset,
196            direction,
197            order,
198            ..
199        } = *self;
200
201        let dt = ctx.input(|i| i.unstable_dt) as f64;
202
203        let mut toasts: Vec<Toast> = ctx.data_mut(|d| d.get_temp(id).unwrap_or_default());
204        toasts.extend(std::mem::take(&mut self.added_toasts));
205        toasts.retain(|toast| toast.options.ttl_sec > 0.0);
206
207        for (i, toast) in toasts.iter_mut().enumerate() {
208            let response = Area::new(id.with("toast").with(i))
209                .anchor(align, offset.to_vec2())
210                .order(order)
211                .interactable(true)
212                .show(ctx, |ui| {
213                    if let Some(add_contents) = self.custom_toast_contents.get_mut(&toast.kind) {
214                        add_contents(ui, toast)
215                    } else {
216                        default_toast_contents(ui, toast)
217                    };
218                })
219                .response;
220
221            if !response.hovered() {
222                toast.options.ttl_sec -= dt;
223                if toast.options.ttl_sec.is_finite() {
224                    ctx.request_repaint_after(Duration::from_secs_f64(
225                        toast.options.ttl_sec.max(0.0),
226                    ));
227                }
228            }
229
230            if toast.options.show_progress {
231                ctx.request_repaint();
232            }
233
234            match direction {
235                Direction::LeftToRight => {
236                    offset.x += response.rect.width() + 10.0;
237                }
238                Direction::RightToLeft => {
239                    offset.x -= response.rect.width() + 10.0;
240                }
241                Direction::TopDown => {
242                    offset.y += response.rect.height() + 10.0;
243                }
244                Direction::BottomUp => {
245                    offset.y -= response.rect.height() + 10.0;
246                }
247            }
248        }
249
250        ctx.data_mut(|d| d.insert_temp(id, toasts));
251    }
252}
253
254fn default_toast_contents(ui: &mut Ui, toast: &mut Toast) -> Response {
255    let inner_margin = 10.0;
256    let frame = Frame::window(ui.style());
257    let response = frame
258        .inner_margin(inner_margin)
259        .stroke(Stroke::NONE)
260        .show(ui, |ui| {
261            ui.horizontal(|ui| {
262                let a = |ui: &mut Ui, toast: &mut Toast| {
263                    if toast.options.show_icon {
264                        ui.label(match toast.kind {
265                            ToastKind::Warning => toast.style.warning_icon.clone(),
266                            ToastKind::Error => toast.style.error_icon.clone(),
267                            ToastKind::Success => toast.style.success_icon.clone(),
268                            _ => toast.style.info_icon.clone(),
269                        });
270                    }
271                };
272                let b = |ui: &mut Ui, toast: &mut Toast| ui.label(toast.text.clone());
273                let c = |ui: &mut Ui, toast: &mut Toast| {
274                    if ui.button(toast.style.close_button_text.clone()).clicked() {
275                        toast.close();
276                    }
277                };
278
279                // Draw the contents in the reverse order on right-to-left layouts
280                // to keep the same look.
281                if ui.layout().prefer_right_to_left() {
282                    c(ui, toast);
283                    b(ui, toast);
284                    a(ui, toast);
285                } else {
286                    a(ui, toast);
287                    b(ui, toast);
288                    c(ui, toast);
289                }
290            })
291        })
292        .response;
293
294    if toast.options.show_progress {
295        progress_bar(ui, &response, toast);
296    }
297
298    // Draw the frame's stroke last
299    let frame_shape = Shape::Rect(RectShape::stroke(
300        response.rect,
301        frame.corner_radius,
302        ui.visuals().window_stroke,
303        StrokeKind::Inside,
304    ));
305    ui.painter().add(frame_shape);
306
307    response
308}
309
310fn progress_bar(ui: &mut Ui, response: &Response, toast: &Toast) {
311    let rounding = CornerRadius {
312        nw: 0,
313        ne: 0,
314        ..ui.visuals().window_corner_radius
315    };
316    let mut clip_rect = response.rect;
317    clip_rect.set_top(clip_rect.bottom() - 2.0);
318    clip_rect.set_right(clip_rect.left() + clip_rect.width() * toast.options.progress() as f32);
319
320    ui.painter().with_clip_rect(clip_rect).rect_filled(
321        response.rect,
322        rounding,
323        ui.visuals().text_color(),
324    );
325}
326
327pub fn __run_test_ui(mut add_contents: impl FnMut(&mut Ui, &Context)) {
328    let ctx = Context::default();
329    let _ = ctx.run(Default::default(), |ctx| {
330        egui::CentralPanel::default().show(ctx, |ui| {
331            add_contents(ui, ctx);
332        });
333    });
334}
335
336pub fn __run_test_ui_with_toasts(mut add_contents: impl FnMut(&mut Ui, &mut Toasts)) {
337    let ctx = Context::default();
338    let _ = ctx.run(Default::default(), |ctx| {
339        egui::CentralPanel::default().show(ctx, |ui| {
340            let mut toasts = Toasts::new();
341            add_contents(ui, &mut toasts);
342        });
343    });
344}