Skip to main content

elegance/
toast.rs

1//! Non-blocking notification toasts.
2//!
3//! Two types cooperate:
4//!
5//! * [`Toast`] — a builder that **enqueues** one notification via
6//!   [`Toast::show`]. Takes only `&Context`, so it can fire from any
7//!   callback that has access to the egui context (button handlers, input
8//!   events, async completion callbacks, …).
9//! * [`Toasts`] — the renderer. Call [`Toasts::new()`]`.render(ctx)` once
10//!   per frame in your top-level `update`. Without this call, enqueued
11//!   toasts silently accumulate and nothing is shown.
12//!
13//! # Usage
14//!
15//! ```no_run
16//! # use elegance::{BadgeTone, Toast, Toasts};
17//! # let ctx = egui::Context::default();
18//! // Somewhere in your update loop:
19//! Toasts::new().render(&ctx);
20//!
21//! // From any callback with access to the context:
22//! Toast::new("Deploy complete")
23//!     .tone(BadgeTone::Ok)
24//!     .description("Rolled out to us-east-1")
25//!     .show(&ctx);
26//! ```
27
28use std::{collections::VecDeque, time::Duration};
29
30use egui::{
31    accesskit, Align2, Area, Color32, Context, CornerRadius, Id, Order, Pos2, Rect, Response,
32    Sense, Stroke, StrokeKind, Ui, Vec2,
33};
34
35use crate::theme::Theme;
36use crate::BadgeTone;
37
38/// How long the fade-out animation takes, in seconds. Counted against
39/// a toast's total lifetime (i.e., the toast disappears at
40/// `birth + duration + FADE_OUT`).
41const FADE_OUT: f64 = 0.20;
42/// Default auto-dismiss duration, in seconds.
43const DEFAULT_DURATION: f64 = 4.0;
44/// Default stack cap — older toasts are dropped when this is exceeded.
45const DEFAULT_MAX_VISIBLE: usize = 5;
46/// Default width of a toast card, in points.
47const DEFAULT_WIDTH: f32 = 320.0;
48/// Vertical gap between stacked toasts, in points.
49const STACK_GAP: f32 = 8.0;
50/// Height of the optional "Clear all" pill, in points.
51const CLEAR_ALL_HEIGHT: f32 = 26.0;
52/// Gap between the "Clear all" pill and the nearest toast, in points.
53const CLEAR_ALL_GAP: f32 = 6.0;
54/// Stack size at or above which the "Clear all" pill becomes worth showing.
55/// At a single toast the per-toast × is enough; the bulk-dismiss
56/// affordance only earns its rendering cost once two or more pile up.
57const CLEAR_ALL_THRESHOLD: usize = 2;
58
59fn storage_id() -> Id {
60    Id::new("elegance::toasts")
61}
62
63/// A single enqueued notification.
64///
65/// Construct with [`Toast::new`], configure via the builder methods, then
66/// call [`Toast::show`] to enqueue. The toast is rendered the next time
67/// [`Toasts::render`] runs.
68#[derive(Debug, Clone)]
69#[must_use = "Call `show(ctx)` to enqueue the toast."]
70pub struct Toast {
71    title: String,
72    description: Option<String>,
73    tone: BadgeTone,
74    duration: Option<Duration>,
75}
76
77impl Toast {
78    /// Create a toast with a title. Defaults: [`BadgeTone::Info`], auto-dismiss
79    /// after `DEFAULT_DURATION` seconds.
80    pub fn new(title: impl Into<String>) -> Self {
81        Self {
82            title: title.into(),
83            description: None,
84            tone: BadgeTone::Info,
85            duration: Some(Duration::from_secs_f64(DEFAULT_DURATION)),
86        }
87    }
88
89    /// Pick the tone (drives the left accent bar colour).
90    pub fn tone(mut self, tone: BadgeTone) -> Self {
91        self.tone = tone;
92        self
93    }
94
95    /// Add a secondary line below the title.
96    pub fn description(mut self, description: impl Into<String>) -> Self {
97        self.description = Some(description.into());
98        self
99    }
100
101    /// Override how long the toast stays visible before it starts fading out.
102    pub fn duration(mut self, duration: Duration) -> Self {
103        self.duration = Some(duration);
104        self
105    }
106
107    /// Disable auto-dismiss. The toast stays until the user clicks × or
108    /// another toast pushes it out of the stack (see [`Toasts::max_visible`]).
109    pub fn persistent(mut self) -> Self {
110        self.duration = None;
111        self
112    }
113
114    /// Enqueue the toast. It is shown on the next frame that renders
115    /// [`Toasts`].
116    pub fn show(self, ctx: &Context) {
117        let now = ctx.input(|i| i.time);
118        ctx.data_mut(|d| {
119            let mut state = d.get_temp::<ToastState>(storage_id()).unwrap_or_default();
120            let id = state.next_id;
121            state.next_id = state.next_id.wrapping_add(1);
122            state.queue.push_back(ToastEntry {
123                id,
124                title: self.title,
125                description: self.description,
126                tone: self.tone,
127                duration: self.duration.map(|d| d.as_secs_f64()),
128                birth: now,
129                dismiss_start: None,
130            });
131            d.insert_temp(storage_id(), state);
132        });
133        ctx.request_repaint();
134    }
135}
136
137/// Renderer for the enqueued toast stack.
138///
139/// Configure placement via the builder, then call [`Toasts::render`] once
140/// per frame. Multiple `Toasts::render` calls per frame are a mistake —
141/// each one will paint the whole stack.
142#[derive(Debug, Clone)]
143#[must_use = "Call `.render(ctx)` to draw the toast stack."]
144pub struct Toasts {
145    anchor: Align2,
146    offset: Vec2,
147    max_visible: usize,
148    width: f32,
149    clear_all_button: bool,
150}
151
152impl Default for Toasts {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158impl Toasts {
159    /// Start a new configuration. Defaults: anchored to the bottom-right
160    /// with a 12-pt offset, up to 5 toasts visible, 320-pt wide.
161    pub fn new() -> Self {
162        Self {
163            anchor: Align2::RIGHT_BOTTOM,
164            offset: Vec2::new(12.0, 12.0),
165            max_visible: DEFAULT_MAX_VISIBLE,
166            width: DEFAULT_WIDTH,
167            clear_all_button: false,
168        }
169    }
170
171    /// Anchor corner for the stack. Default: [`Align2::RIGHT_BOTTOM`].
172    pub fn anchor(mut self, anchor: Align2) -> Self {
173        self.anchor = anchor;
174        self
175    }
176
177    /// Offset from the anchor corner, in points. Default: `(12, 12)`.
178    pub fn offset(mut self, offset: impl Into<Vec2>) -> Self {
179        self.offset = offset.into();
180        self
181    }
182
183    /// Maximum number of toasts rendered at once. Oldest are dropped when
184    /// the cap is exceeded. Default: 5.
185    pub fn max_visible(mut self, max_visible: usize) -> Self {
186        self.max_visible = max_visible.max(1);
187        self
188    }
189
190    /// Width of each toast card in points. Default: 320.
191    pub fn width(mut self, width: f32) -> Self {
192        self.width = width.max(120.0);
193        self
194    }
195
196    /// Show a "Clear all" pill at the far end of the stack (above the
197    /// topmost toast for bottom-anchored stacks, below the bottommost
198    /// for top-anchored). Appears only when at least two non-dismissing
199    /// toasts are visible; clicking it starts the fade-out animation on
200    /// every entry at once. Default: `false`.
201    pub fn clear_all_button(mut self, enabled: bool) -> Self {
202        self.clear_all_button = enabled;
203        self
204    }
205
206    /// Render the enqueued toast stack. Call once per frame.
207    pub fn render(self, ctx: &Context) {
208        let theme = Theme::current(ctx);
209        let now = ctx.input(|i| i.time);
210
211        // Snapshot state under a short lock, then hand the lock back.
212        let mut state = ctx
213            .data_mut(|d| d.get_temp::<ToastState>(storage_id()))
214            .unwrap_or_default();
215
216        // Expire fully-faded toasts, then cap the queue to max_visible by
217        // dropping oldest (front).
218        state.queue.retain(|entry| !entry.is_expired(now));
219        while state.queue.len() > self.max_visible {
220            state.queue.pop_front();
221        }
222
223        if state.queue.is_empty() {
224            ctx.data_mut(|d| d.insert_temp(storage_id(), state));
225            return;
226        }
227
228        // Paint toasts. We lay out manually (not via egui's own stacking)
229        // so we can track sizes on the Area that each toast lives in.
230        let screen = ctx.content_rect();
231        let stack_up = matches!(self.anchor.y(), egui::Align::Max);
232
233        // Compute each toast's height so we can stack them without depending
234        // on previous-frame measurements.
235        let entry_heights: Vec<f32> = state
236            .queue
237            .iter()
238            .map(|e| measure_height(ctx, &theme, e, self.width))
239            .collect();
240
241        // x position of the stack.
242        let x = match self.anchor.x() {
243            egui::Align::Min => screen.min.x + self.offset.x,
244            egui::Align::Center => screen.center().x - self.width * 0.5,
245            egui::Align::Max => screen.max.x - self.offset.x - self.width,
246        };
247
248        // Starting y and step direction.
249        let (mut y, step_sign): (f32, f32) = if stack_up {
250            (screen.max.y - self.offset.y, -1.0)
251        } else {
252            (screen.min.y + self.offset.y, 1.0)
253        };
254
255        // Newest toast sits closest to the anchor edge; iterate accordingly.
256        let order_is_new_to_old = stack_up;
257        let indices: Vec<usize> = if order_is_new_to_old {
258            (0..state.queue.len()).rev().collect()
259        } else {
260            (0..state.queue.len()).collect()
261        };
262
263        let mut dismiss_ids: Vec<u64> = Vec::new();
264        let mut earliest_next_event: Option<f64> = None;
265        let mut any_animating = false;
266
267        for i in indices {
268            let entry = &state.queue[i];
269            let h = entry_heights[i];
270
271            let (top, bottom) = if step_sign < 0.0 {
272                (y - h, y)
273            } else {
274                (y, y + h)
275            };
276            let rect = Rect::from_min_max(Pos2::new(x, top), Pos2::new(x + self.width, bottom));
277
278            // Animating = currently in fade-in or fade-out.
279            let (alpha, is_animating, next_event) = entry.alpha_and_schedule(now);
280            any_animating |= is_animating;
281            if let Some(t) = next_event {
282                earliest_next_event = Some(match earliest_next_event {
283                    Some(prev) => prev.min(t),
284                    None => t,
285                });
286            }
287
288            let area_id = Id::new(("elegance::toast", entry.id));
289            let resp = Area::new(area_id)
290                .order(Order::Tooltip)
291                .fixed_pos(rect.min)
292                .show(ctx, |ui| paint_toast(ui, &theme, entry, rect, alpha));
293
294            if resp.inner {
295                dismiss_ids.push(entry.id);
296            }
297
298            // Advance the cursor for the next toast.
299            let delta = (h + STACK_GAP) * step_sign;
300            y += delta;
301        }
302
303        // Record clicks into dismiss_start so next frame's alpha math picks
304        // them up.
305        if !dismiss_ids.is_empty() {
306            for entry in state.queue.iter_mut() {
307                if dismiss_ids.contains(&entry.id) && entry.dismiss_start.is_none() {
308                    entry.dismiss_start = Some(now);
309                }
310            }
311        }
312
313        // Optional "Clear all" pill anchored at the far end of the stack.
314        // Counted on non-dismissing entries so the pill hides as soon as
315        // a bulk dismiss is triggered (those toasts then fade out).
316        let active_count = state
317            .queue
318            .iter()
319            .filter(|e| e.dismiss_start.is_none())
320            .count();
321        if self.clear_all_button && active_count >= CLEAR_ALL_THRESHOLD {
322            let total_h: f32 = entry_heights.iter().sum::<f32>()
323                + STACK_GAP * entry_heights.len().saturating_sub(1) as f32;
324            let pill_top = if stack_up {
325                (screen.max.y - self.offset.y) - total_h - CLEAR_ALL_GAP - CLEAR_ALL_HEIGHT
326            } else {
327                (screen.min.y + self.offset.y) + total_h + CLEAR_ALL_GAP
328            };
329            let pill_rect = Rect::from_min_size(
330                Pos2::new(x, pill_top),
331                Vec2::new(self.width, CLEAR_ALL_HEIGHT),
332            );
333
334            let area_id = Id::new("elegance::toast::clear_all");
335            let resp = Area::new(area_id)
336                .order(Order::Tooltip)
337                .fixed_pos(pill_rect.min)
338                .show(ctx, |ui| paint_clear_all(ui, &theme, pill_rect));
339
340            if resp.inner {
341                for entry in state.queue.iter_mut() {
342                    if entry.dismiss_start.is_none() {
343                        entry.dismiss_start = Some(now);
344                    }
345                }
346                any_animating = true;
347            }
348        }
349
350        ctx.data_mut(|d| d.insert_temp(storage_id(), state));
351
352        // Keep animating smoothly; otherwise schedule the next transition.
353        if any_animating {
354            ctx.request_repaint();
355        } else if let Some(at) = earliest_next_event {
356            let remaining = (at - now).max(0.0);
357            ctx.request_repaint_after(Duration::from_secs_f64(remaining));
358        }
359    }
360}
361
362// -- internals ---------------------------------------------------------------
363
364#[derive(Clone, Default)]
365struct ToastState {
366    queue: VecDeque<ToastEntry>,
367    next_id: u64,
368}
369
370#[derive(Clone)]
371struct ToastEntry {
372    id: u64,
373    title: String,
374    description: Option<String>,
375    tone: BadgeTone,
376    /// Auto-dismiss duration in seconds. `None` = persistent.
377    duration: Option<f64>,
378    /// Context time when the toast was enqueued.
379    birth: f64,
380    /// Context time when the user clicked ×. Triggers an immediate fade-out.
381    dismiss_start: Option<f64>,
382}
383
384impl ToastEntry {
385    /// Has the fade-out animation completed?
386    fn is_expired(&self, now: f64) -> bool {
387        if let Some(ds) = self.dismiss_start {
388            return now >= ds + FADE_OUT;
389        }
390        if let Some(d) = self.duration {
391            return now >= self.birth + d + FADE_OUT;
392        }
393        false
394    }
395
396    /// Returns `(alpha, is_animating, next_transition_time)`.
397    ///
398    /// Toasts appear at full opacity and fade out only. `is_animating` is
399    /// true while the fade-out is in progress (we repaint continuously
400    /// during it). `next_transition_time` is `Some(t)` when the toast is
401    /// still at full opacity and we want a single deferred repaint at `t`
402    /// to start the fade-out.
403    fn alpha_and_schedule(&self, now: f64) -> (f32, bool, Option<f64>) {
404        // Fade-out: either explicit dismiss, or past the auto-dismiss instant.
405        let fade_out_start = match self.dismiss_start {
406            Some(ds) => Some(ds),
407            None => self.duration.map(|d| self.birth + d),
408        };
409
410        match fade_out_start {
411            Some(t0) if now >= t0 => {
412                let progress = ((now - t0) / FADE_OUT).clamp(0.0, 1.0) as f32;
413                (1.0 - progress, progress < 1.0, None)
414            }
415            Some(t0) => (1.0, false, Some(t0)),
416            None => (1.0, false, None),
417        }
418    }
419}
420
421fn tone_accent(theme: &Theme, tone: BadgeTone) -> Color32 {
422    let p = &theme.palette;
423    match tone {
424        BadgeTone::Ok => p.success,
425        BadgeTone::Warning => p.warning,
426        BadgeTone::Danger => p.danger,
427        BadgeTone::Info => p.sky,
428        BadgeTone::Neutral => p.text_muted,
429    }
430}
431
432fn apply_alpha(color: Color32, alpha: f32) -> Color32 {
433    let a = (color.a() as f32 * alpha.clamp(0.0, 1.0)).round() as u8;
434    Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a)
435}
436
437/// Layout constants shared between measurement and painting.
438mod layout {
439    pub const PAD_X: f32 = 14.0;
440    pub const PAD_Y: f32 = 10.0;
441    pub const BAR_W: f32 = 3.0;
442    pub const BAR_GAP: f32 = 10.0;
443    pub const TITLE_DESC_GAP: f32 = 3.0;
444    pub const CLOSE_W: f32 = 18.0;
445    pub const CLOSE_GAP: f32 = 8.0;
446    pub const TEXT_LEFT_NUDGE: f32 = 4.0;
447
448    /// Shared so `measure_height` and `paint_toast` wrap against the same
449    /// width — otherwise the stack lays out against a height the paint
450    /// path doesn't reproduce.
451    pub fn text_wrap_width(card_width: f32) -> f32 {
452        (card_width - PAD_X * 1.5 - BAR_W - BAR_GAP - CLOSE_W - CLOSE_GAP + TEXT_LEFT_NUDGE)
453            .max(1.0)
454    }
455}
456
457fn measure_height(ctx: &Context, theme: &Theme, entry: &ToastEntry, width: f32) -> f32 {
458    use layout::*;
459    let t = &theme.typography;
460
461    // Lay out with Color32::PLACEHOLDER so the galley cache entry is shared
462    // with paint_toast, which fills the final (alpha'd) color at paint time
463    // via painter.galley(..., fallback_color). Using a concrete color here
464    // would produce a different cache key and double the work during fades.
465    let text_width = text_wrap_width(width);
466    let title_galley = ctx.fonts_mut(|f| {
467        f.layout(
468            entry.title.clone(),
469            egui::FontId::proportional(t.body),
470            Color32::PLACEHOLDER,
471            text_width,
472        )
473    });
474
475    let mut h = PAD_Y * 2.0 + title_galley.size().y;
476    if let Some(desc) = &entry.description {
477        let desc_galley = ctx.fonts_mut(|f| {
478            f.layout(
479                desc.clone(),
480                egui::FontId::proportional(t.small),
481                Color32::PLACEHOLDER,
482                text_width,
483            )
484        });
485        h += TITLE_DESC_GAP + desc_galley.size().y;
486    }
487    h.max(44.0)
488}
489
490/// Paint a single toast inside its area. Returns `true` if the close button
491/// was clicked this frame.
492fn paint_toast(ui: &mut Ui, theme: &Theme, entry: &ToastEntry, rect: Rect, alpha: f32) -> bool {
493    use layout::*;
494    let p = &theme.palette;
495    let t = &theme.typography;
496
497    // Upgrade the Area's Ui role from `GenericContainer` (set by
498    // `Ui::new`) to an ARIA live-region role. Danger/Warning toasts use
499    // `Role::Alert` (assertive — interrupts the user); others use
500    // `Role::Status` (polite — announced after current speech).
501    let role = match entry.tone {
502        BadgeTone::Danger | BadgeTone::Warning => accesskit::Role::Alert,
503        _ => accesskit::Role::Status,
504    };
505    let label = entry.title.clone();
506    let description = entry.description.clone();
507    ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
508        node.set_role(role);
509        node.set_label(label);
510        if let Some(d) = description {
511            node.set_description(d);
512        }
513    });
514
515    // Claim the full toast rect so clicks don't pass through to widgets beneath.
516    ui.allocate_rect(rect, Sense::hover());
517    let painter = ui.painter();
518
519    // Card background.
520    let bg = apply_alpha(p.depth_tint(p.card, 0.04), alpha);
521    let border = apply_alpha(p.border, alpha);
522    painter.rect(
523        rect,
524        CornerRadius::same(theme.card_radius as u8),
525        bg,
526        Stroke::new(1.0, border),
527        StrokeKind::Inside,
528    );
529
530    // Left accent bar.
531    let accent = apply_alpha(tone_accent(theme, entry.tone), alpha);
532    let bar_rect = Rect::from_min_max(
533        Pos2::new(rect.min.x + 4.0, rect.min.y + 6.0),
534        Pos2::new(rect.min.x + 4.0 + BAR_W, rect.max.y - 6.0),
535    );
536    painter.rect_filled(bar_rect, CornerRadius::same(2), accent);
537
538    // Close × in the top-right.
539    let close_rect = Rect::from_min_size(
540        Pos2::new(rect.max.x - PAD_X * 0.5 - CLOSE_W, rect.min.y + 6.0),
541        Vec2::new(CLOSE_W, CLOSE_W),
542    );
543    let close_resp: Response = ui.allocate_rect(close_rect, Sense::click());
544    let close_color = if close_resp.hovered() {
545        apply_alpha(p.text, alpha)
546    } else {
547        apply_alpha(p.text_muted, alpha)
548    };
549    let close_galley = crate::theme::placeholder_galley(ui, "×", t.body + 2.0, true, f32::INFINITY);
550    let close_text_pos = Pos2::new(
551        close_rect.center().x - close_galley.size().x * 0.5,
552        close_rect.center().y - close_galley.size().y * 0.5,
553    );
554    ui.painter()
555        .galley(close_text_pos, close_galley, close_color);
556
557    // Text block: title + optional description, to the right of the bar.
558    let text_left = rect.min.x + PAD_X + BAR_W + BAR_GAP - TEXT_LEFT_NUDGE;
559    let text_width = text_wrap_width(rect.width());
560
561    let title_color = apply_alpha(p.text, alpha);
562    let desc_color = apply_alpha(p.text_muted, alpha);
563
564    // Lay out with Color32::PLACEHOLDER and supply the real (alpha'd) color
565    // to painter.galley as fallback_color. This shares the cache entry with
566    // measure_height and avoids a fresh layout every frame during the fade.
567    let title_galley = ui.ctx().fonts_mut(|f| {
568        f.layout(
569            entry.title.clone(),
570            egui::FontId::proportional(t.body),
571            Color32::PLACEHOLDER,
572            text_width,
573        )
574    });
575    let title_size_y = title_galley.size().y;
576    let title_pos = Pos2::new(text_left, rect.min.y + PAD_Y);
577    ui.painter().galley(title_pos, title_galley, title_color);
578
579    if let Some(desc) = &entry.description {
580        let desc_galley = ui.ctx().fonts_mut(|f| {
581            f.layout(
582                desc.clone(),
583                egui::FontId::proportional(t.small),
584                Color32::PLACEHOLDER,
585                text_width,
586            )
587        });
588        let desc_pos = Pos2::new(
589            text_left,
590            rect.min.y + PAD_Y + title_size_y + TITLE_DESC_GAP,
591        );
592        ui.painter().galley(desc_pos, desc_galley, desc_color);
593    }
594
595    close_resp.clicked()
596}
597
598/// Paint the "Clear all" pill that sits above (or below) the toast
599/// stack when [`Toasts::clear_all_button`] is enabled and the stack has
600/// at least [`CLEAR_ALL_THRESHOLD`] non-dismissing entries. Returns
601/// `true` if it was clicked this frame.
602fn paint_clear_all(ui: &mut Ui, theme: &Theme, rect: Rect) -> bool {
603    let p = &theme.palette;
604    let t = &theme.typography;
605
606    ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
607        node.set_role(accesskit::Role::Button);
608        node.set_label("Clear all notifications");
609    });
610
611    let resp = ui.allocate_rect(rect, Sense::click());
612    let painter = ui.painter();
613
614    let bg = if resp.hovered() {
615        p.depth_tint(p.card, 0.10)
616    } else {
617        p.depth_tint(p.card, 0.04)
618    };
619    let radius = CornerRadius::same((rect.height() * 0.5).round() as u8);
620    painter.rect(
621        rect,
622        radius,
623        bg,
624        Stroke::new(1.0, p.border),
625        StrokeKind::Inside,
626    );
627
628    let text_color = if resp.hovered() { p.text } else { p.text_muted };
629    let galley = crate::theme::placeholder_galley(ui, "Clear all", t.small, false, f32::INFINITY);
630    let text_pos = Pos2::new(
631        rect.center().x - galley.size().x * 0.5,
632        rect.center().y - galley.size().y * 0.5,
633    );
634    painter.galley(text_pos, galley, text_color);
635
636    resp.clicked()
637}