Skip to main content

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| {
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(ui);
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| {
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| {
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::{
90    Align2, Area, Context, CornerRadius, Direction, Frame, Id, Order, Pos2, Response, Sense,
91    Stroke, Ui,
92};
93
94pub type ToastContents = dyn Fn(&mut Ui, &mut Toast) -> Response + Send + Sync;
95
96pub struct Toasts {
97    id: Id,
98    align: Align2,
99    offset: Pos2,
100    direction: Direction,
101    order: Order,
102    custom_toast_contents: HashMap<ToastKind, Arc<ToastContents>>,
103    /// Toasts added since the last draw call. These are moved to the
104    /// egui context's memory, so you are free to recreate the [`Toasts`] instance every frame.
105    added_toasts: Vec<Toast>,
106}
107
108impl Default for Toasts {
109    fn default() -> Self {
110        Self {
111            id: Id::new("__toasts"),
112            align: Align2::LEFT_TOP,
113            offset: Pos2::new(10.0, 10.0),
114            direction: Direction::TopDown,
115            order: Order::Foreground,
116            custom_toast_contents: HashMap::new(),
117            added_toasts: Vec::new(),
118        }
119    }
120}
121
122impl Toasts {
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Create a new [`Toasts`] instance with a custom id
128    ///
129    /// This can be useful if you want to have multiple toast groups
130    /// in the same UI.
131    pub fn with_id(id: Id) -> Self {
132        Self {
133            id,
134            ..Default::default()
135        }
136    }
137
138    /// Set the layer order for the toasts.
139    ///
140    /// Default is [`Order::Foreground`]. Use [`Order::Tooltip`] to ensure
141    /// toasts appear above modals and other foreground elements.
142    pub fn order(mut self, order: Order) -> Self {
143        self.order = order;
144        self
145    }
146
147    /// Position where the toasts show up.
148    ///
149    /// The toasts will start from this position and stack up
150    /// in the direction specified with [`Self::direction`].
151    pub fn position(mut self, position: impl Into<Pos2>) -> Self {
152        self.offset = position.into();
153        self
154    }
155
156    /// Anchor for the toasts.
157    ///
158    /// For instance, if you set this to (10.0, 10.0) and [`Align2::LEFT_TOP`],
159    /// then (10.0, 10.0) will be the top-left corner of the first toast.
160    pub fn anchor(mut self, anchor: Align2, offset: impl Into<Pos2>) -> Self {
161        self.align = anchor;
162        self.offset = offset.into();
163        self
164    }
165
166    /// Direction where the toasts stack up
167    pub fn direction(mut self, direction: impl Into<Direction>) -> Self {
168        self.direction = direction.into();
169        self
170    }
171
172    /// Can be used to specify a custom rendering function for toasts for given kind
173    pub fn custom_contents(
174        mut self,
175        kind: impl Into<ToastKind>,
176        add_contents: impl Fn(&mut Ui, &mut Toast) -> Response + Send + Sync + 'static,
177    ) -> Self {
178        self.custom_toast_contents
179            .insert(kind.into(), Arc::new(add_contents));
180        self
181    }
182
183    /// Add a new toast
184    pub fn add(&mut self, toast: Toast) -> &mut Self {
185        self.added_toasts.push(toast);
186        self
187    }
188
189    /// Show and update all toasts
190    pub fn show(&mut self, ui: &mut Ui) {
191        let Self {
192            id,
193            align,
194            mut offset,
195            direction,
196            order,
197            ..
198        } = *self;
199
200        let dt = ui.input(|i| i.unstable_dt) as f64;
201
202        let mut toasts: Vec<Toast> = ui.data_mut(|d| d.get_temp(id).unwrap_or_default());
203        toasts.extend(std::mem::take(&mut self.added_toasts));
204        toasts.retain(|toast| toast.options.ttl_sec > 0.0);
205
206        for (i, toast) in toasts.iter_mut().enumerate() {
207            let response = Area::new(id.with("toast").with(i))
208                .anchor(align, offset.to_vec2())
209                .order(order)
210                .interactable(true)
211                .show(ui, |ui| {
212                    if let Some(add_contents) = self.custom_toast_contents.get_mut(&toast.kind) {
213                        add_contents(ui, toast)
214                    } else {
215                        default_toast_contents(ui, toast)
216                    };
217                })
218                .response;
219
220            if !response.hovered() {
221                toast.options.ttl_sec -= dt;
222                if toast.options.ttl_sec.is_finite() {
223                    ui.request_repaint_after(Duration::from_secs_f64(
224                        toast.options.ttl_sec.max(0.0),
225                    ));
226                }
227            }
228
229            if toast.options.show_progress {
230                ui.request_repaint();
231            }
232
233            match direction {
234                Direction::LeftToRight => {
235                    offset.x += response.rect.width() + 10.0;
236                }
237                Direction::RightToLeft => {
238                    offset.x -= response.rect.width() + 10.0;
239                }
240                Direction::TopDown => {
241                    offset.y += response.rect.height() + 10.0;
242                }
243                Direction::BottomUp => {
244                    offset.y -= response.rect.height() + 10.0;
245                }
246            }
247        }
248
249        ui.data_mut(|d| d.insert_temp(id, toasts));
250    }
251}
252
253fn default_toast_contents(ui: &mut Ui, toast: &mut Toast) -> Response {
254    let inner_margin = 10.0;
255    let frame = Frame::window(ui.style()).shadow(egui::Shadow::NONE);
256    let response = frame
257        .inner_margin(inner_margin)
258        .stroke(Stroke::NONE)
259        .show(ui, |ui| {
260            ui.horizontal(|ui| {
261                let a = |ui: &mut Ui, toast: &mut Toast| {
262                    if toast.options.show_icon {
263                        ui.label(match toast.kind {
264                            ToastKind::Warning => toast.style.warning_icon.clone(),
265                            ToastKind::Error => toast.style.error_icon.clone(),
266                            ToastKind::Success => toast.style.success_icon.clone(),
267                            _ => toast.style.info_icon.clone(),
268                        });
269                    }
270                };
271                let b = |ui: &mut Ui, toast: &mut Toast| ui.label(toast.text.clone());
272
273                // Draw the contents in the reverse order on right-to-left layouts
274                // to keep the same look.
275                if ui.layout().prefer_right_to_left() {
276                    b(ui, toast);
277                    a(ui, toast);
278                } else {
279                    a(ui, toast);
280                    b(ui, toast);
281                }
282            })
283        })
284        .response;
285
286    if toast.options.show_progress {
287        progress_bar(ui, &response, toast);
288    }
289
290    let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
291    if response.interact(Sense::click()).clicked() {
292        toast.close();
293    }
294
295    response
296}
297
298fn progress_bar(ui: &mut Ui, response: &Response, toast: &Toast) {
299    let rounding = CornerRadius {
300        nw: 0,
301        ne: 0,
302        ..ui.visuals().window_corner_radius
303    };
304    let mut clip_rect = response.rect;
305    clip_rect.set_top(clip_rect.bottom() - 2.0);
306    clip_rect.set_right(clip_rect.left() + clip_rect.width() * toast.options.progress() as f32);
307
308    ui.painter().with_clip_rect(clip_rect).rect_filled(
309        response.rect,
310        rounding,
311        ui.visuals().text_color(),
312    );
313}
314
315pub fn __run_test_ui(mut add_contents: impl FnMut(&mut Ui)) {
316    let ctx = Context::default();
317    let _ = ctx.run_ui(Default::default(), |ui| {
318        egui::CentralPanel::default().show_inside(ui, |ui| {
319            add_contents(ui);
320        });
321    });
322}