Skip to main content

egui_components/
notification.rs

1//! `Notification` toasts — transient messages stacked in a screen corner.
2//!
3//! Hold a [`Toasts`] in your app state, push messages onto it, and call
4//! [`Toasts::show`] once per frame. Toasts fade in, auto-dismiss after their
5//! duration (unless hovered), and can be closed manually.
6//!
7//! ```ignore
8//! // in app state: toasts: sc::Toasts,
9//! if ui.button("Notify").clicked() {
10//!     self.toasts.success("Saved", "Your changes were stored.");
11//! }
12//! self.toasts.show(ui.ctx());
13//! ```
14
15use egui::{vec2, Align2, Area, Color32, Frame, Id, Margin, Order, Sense, Stroke};
16use egui_components_theme::{mix, Theme};
17
18use crate::common::Variant;
19use crate::icon::{paint_icon, IconKind};
20
21/// Where toasts stack on screen.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ToastAnchor {
24    TopRight,
25    TopLeft,
26    BottomRight,
27    BottomLeft,
28}
29
30struct Toast {
31    id: Id,
32    variant: Variant,
33    title: Option<String>,
34    message: String,
35    duration: f64,
36    /// Wall-clock time (ctx time) at which the toast was created.
37    created: Option<f64>,
38}
39
40/// A stack of active toasts. Cheap to keep in app state.
41pub struct Toasts {
42    anchor: ToastAnchor,
43    width: f32,
44    gap: f32,
45    margin: f32,
46    next_id: u64,
47    toasts: Vec<Toast>,
48}
49
50impl Default for Toasts {
51    fn default() -> Self {
52        Self {
53            anchor: ToastAnchor::TopRight,
54            width: 320.0,
55            gap: 8.0,
56            margin: 16.0,
57            next_id: 0,
58            toasts: Vec::new(),
59        }
60    }
61}
62
63impl Toasts {
64    pub fn new() -> Self {
65        Self::default()
66    }
67    pub fn anchor(mut self, anchor: ToastAnchor) -> Self {
68        self.anchor = anchor;
69        self
70    }
71    pub fn width(mut self, w: f32) -> Self {
72        self.width = w;
73        self
74    }
75
76    /// Push a toast with an explicit variant and 4s duration.
77    pub fn add(&mut self, variant: Variant, title: Option<String>, message: impl Into<String>) {
78        let id = Id::new(("toast", self.next_id));
79        self.next_id = self.next_id.wrapping_add(1);
80        self.toasts.push(Toast {
81            id,
82            variant,
83            title,
84            message: message.into(),
85            duration: 4.0,
86            created: None,
87        });
88    }
89
90    pub fn info(&mut self, title: impl Into<String>, message: impl Into<String>) {
91        self.add(Variant::Info, Some(title.into()), message);
92    }
93    pub fn success(&mut self, title: impl Into<String>, message: impl Into<String>) {
94        self.add(Variant::Success, Some(title.into()), message);
95    }
96    pub fn warning(&mut self, title: impl Into<String>, message: impl Into<String>) {
97        self.add(Variant::Warning, Some(title.into()), message);
98    }
99    pub fn error(&mut self, title: impl Into<String>, message: impl Into<String>) {
100        self.add(Variant::Danger, Some(title.into()), message);
101    }
102
103    /// Render the active toasts and drop the expired ones.
104    pub fn show(&mut self, ctx: &egui::Context) {
105        if self.toasts.is_empty() {
106            return;
107        }
108        let now = ctx.input(|i| i.time);
109        let theme = Theme::get(ctx);
110
111        let (pivot, base) = match self.anchor {
112            ToastAnchor::TopRight => (
113                Align2::RIGHT_TOP,
114                ctx.content_rect().right_top() + vec2(-self.margin, self.margin),
115            ),
116            ToastAnchor::TopLeft => (
117                Align2::LEFT_TOP,
118                ctx.content_rect().left_top() + vec2(self.margin, self.margin),
119            ),
120            ToastAnchor::BottomRight => (
121                Align2::RIGHT_BOTTOM,
122                ctx.content_rect().right_bottom() + vec2(-self.margin, -self.margin),
123            ),
124            ToastAnchor::BottomLeft => (
125                Align2::LEFT_BOTTOM,
126                ctx.content_rect().left_bottom() + vec2(self.margin, -self.margin),
127            ),
128        };
129        let stack_down = matches!(self.anchor, ToastAnchor::TopRight | ToastAnchor::TopLeft);
130
131        let mut remove: Vec<Id> = Vec::new();
132        let mut offset_y = 0.0;
133        let mut need_repaint = false;
134
135        for toast in self.toasts.iter_mut() {
136            if toast.created.is_none() {
137                toast.created = Some(now);
138            }
139            let age = now - toast.created.unwrap_or(now);
140            let anchor_pos = base + vec2(0.0, if stack_down { offset_y } else { -offset_y });
141
142            let resp = Area::new(toast.id)
143                .order(Order::Foreground)
144                .fixed_pos(anchor_pos)
145                .pivot(pivot)
146                .show(ctx, |ui| {
147                    ui.set_width(self.width);
148                    paint_toast(ui, &theme, toast)
149                });
150
151            let card_h = resp.response.rect.height();
152            offset_y += card_h + self.gap;
153
154            let hovered = resp.response.hovered();
155            if resp.inner {
156                // Close button clicked.
157                remove.push(toast.id);
158            } else if !hovered && age >= toast.duration {
159                remove.push(toast.id);
160            } else if !hovered {
161                need_repaint = true;
162            }
163        }
164
165        self.toasts.retain(|t| !remove.contains(&t.id));
166        if need_repaint {
167            ctx.request_repaint();
168        }
169    }
170}
171
172/// Returns true if the close button was clicked.
173fn paint_toast(ui: &mut egui::Ui, theme: &Theme, toast: &Toast) -> bool {
174    let c = theme.colors;
175    let (accent, icon) = toast_accent(&c, toast.variant);
176
177    let mut close_clicked = false;
178    Frame::new()
179        .fill(c.popover_background)
180        .stroke(Stroke::new(theme.metrics.border_width, c.border))
181        .corner_radius(theme.corner())
182        .inner_margin(Margin::same(12))
183        .shadow(egui::epaint::Shadow {
184            offset: [0, 4],
185            blur: 18,
186            spread: 0,
187            color: c.overlay,
188        })
189        .show(ui, |ui| {
190            ui.horizontal_top(|ui| {
191                // Accent icon.
192                let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
193                paint_icon(ui.painter(), icon, ir, accent, 1.8);
194                ui.add_space(8.0);
195
196                ui.vertical(|ui| {
197                    ui.set_width(ui.available_width() - 22.0);
198                    if let Some(title) = &toast.title {
199                        ui.add(
200                            crate::label::Label::new(title.clone())
201                                .strong()
202                                .size(crate::common::Size::Small),
203                        );
204                    }
205                    ui.add(
206                        crate::label::Label::new(toast.message.clone())
207                            .muted()
208                            .size(crate::common::Size::Small),
209                    );
210                });
211
212                // Close (×) button.
213                let (x_rect, x_resp) =
214                    ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
215                let x_color = if x_resp.hovered() {
216                    c.foreground
217                } else {
218                    c.muted_foreground
219                };
220                paint_icon(ui.painter(), IconKind::Close, x_rect, x_color, 1.4);
221                if x_resp.clicked() {
222                    close_clicked = true;
223                }
224                if x_resp.hovered() {
225                    ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
226                }
227            });
228        });
229
230    close_clicked
231}
232
233fn toast_accent(c: &egui_components_theme::ThemeColor, v: Variant) -> (Color32, IconKind) {
234    match v {
235        Variant::Success => (c.success_background, IconKind::Check),
236        Variant::Warning => (c.warning_background, IconKind::Warning),
237        Variant::Danger => (c.danger_background, IconKind::Error),
238        Variant::Info => (c.info_background, IconKind::Info),
239        _ => (mix(c.foreground, c.muted_foreground, 0.3), IconKind::Info),
240    }
241}