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