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}