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