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}