Skip to main content

aetna_core/
toast.rs

1//! Runtime-synthesized toast notifications.
2//!
3//! Apps push toasts via [`crate::App::drain_toasts`]; the runtime stamps each
4//! with a monotonic id + an expiry, queues it in [`UiState`],
5//! and synthesizes a `Kind::Custom("toast_stack")` floating layer at
6//! the El root each frame. The layer is bottom-right anchored, hit-test
7//! transparent except for the per-toast dismiss button (which the
8//! runtime intercepts in `pointer_up` and removes the toast on).
9//!
10//! This mirrors [`crate::tooltip`]: tree is the source of truth at
11//! frame end, but the *triggers* (hover for tooltips, fire-and-forget
12//! for toasts) are runtime-managed because composing them by hand each
13//! frame would be a lot of per-app plumbing for a behaviour every UI
14//! shares.
15
16use std::time::Duration;
17
18use web_time::Instant;
19
20use crate::state::UiState;
21use crate::style::StyleProfile;
22use crate::tokens;
23use crate::tree::*;
24use crate::widgets::button::button;
25
26/// Default time a toast stays on screen before auto-dismissing.
27/// Matches the shadcn / Sonner default. Apps override per-toast via
28/// [`ToastSpec::with_ttl`].
29pub const DEFAULT_TOAST_TTL: Duration = Duration::from_secs(4);
30
31/// Severity / variant for a toast. Drives the leading icon and the
32/// surface accent colour. Mirrors the shadcn `<Toast variant="...">`
33/// vocabulary.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum ToastLevel {
36    Default,
37    Success,
38    Warning,
39    Error,
40    Info,
41}
42
43/// What the app produces from [`crate::App::drain_toasts`]. The
44/// runtime stamps an `id` + computes `expires_at` when it queues
45/// the toast into [`UiState`]'s runtime queue.
46#[derive(Clone, Debug)]
47pub struct ToastSpec {
48    pub level: ToastLevel,
49    pub message: String,
50    pub ttl: Duration,
51}
52
53impl ToastSpec {
54    pub fn new(level: ToastLevel, message: impl Into<String>) -> Self {
55        Self {
56            level,
57            message: message.into(),
58            ttl: DEFAULT_TOAST_TTL,
59        }
60    }
61    pub fn default(message: impl Into<String>) -> Self {
62        Self::new(ToastLevel::Default, message)
63    }
64    pub fn success(message: impl Into<String>) -> Self {
65        Self::new(ToastLevel::Success, message)
66    }
67    pub fn warning(message: impl Into<String>) -> Self {
68        Self::new(ToastLevel::Warning, message)
69    }
70    pub fn error(message: impl Into<String>) -> Self {
71        Self::new(ToastLevel::Error, message)
72    }
73    pub fn info(message: impl Into<String>) -> Self {
74        Self::new(ToastLevel::Info, message)
75    }
76    pub fn with_ttl(mut self, ttl: Duration) -> Self {
77        self.ttl = ttl;
78        self
79    }
80}
81
82/// A queued toast — id stamped by the runtime on enqueue, used both
83/// as the dismiss-button suffix and to drop the right entry when
84/// the X is clicked or the TTL elapses.
85#[derive(Clone, Debug)]
86pub struct Toast {
87    pub id: u64,
88    pub level: ToastLevel,
89    pub message: String,
90    pub expires_at: Instant,
91}
92
93/// Runtime synthesis pass: drop expired toasts, then append a
94/// floating `toast_stack` layer if any remain. Called from
95/// `prepare_layout` after [`crate::tooltip::synthesize_tooltip`].
96/// Returns `true` while any toast is pending so the host keeps the
97/// redraw loop alive long enough to drop the next-to-expire toast.
98///
99/// **Root precondition:** the synthesized layer is appended as a
100/// sibling of whatever the app returned from [`crate::App::build`].
101/// For it to overlay (rather than compete for flex space) the root
102/// must be an `Axis::Overlay` container — typically `overlays(main,
103/// [])`, which is the same convention apps use for user-composed
104/// popovers and modals. Debug builds panic on a non-overlay root.
105pub fn synthesize_toasts(root: &mut El, ui_state: &mut UiState, now: Instant) -> bool {
106    ui_state.toast.queue.retain(|t| t.expires_at > now);
107    if ui_state.toast.queue.is_empty() {
108        return false;
109    }
110    debug_assert_eq!(
111        root.axis,
112        Axis::Overlay,
113        "synthesize_toasts: root must be an Axis::Overlay container so the toast \
114         stack overlays the main view. Wrap your `App::build` return value in \
115         `overlays(main, [])`. Got axis = {:?}",
116        root.axis,
117    );
118    let cards: Vec<El> = ui_state.toast.queue.iter().map(toast_card).collect();
119    root.children.push(toast_stack(cards));
120    // Assign computed_ids to the pushed layer in-place so the
121    // subsequent `layout_post_assign` doesn't have to re-walk the
122    // whole tree. Pairs with `RunnerCore::prepare_layout`'s
123    // skip-the-second-id-walk flow.
124    let i = root.children.len() - 1;
125    crate::layout::assign_id_appended(&root.computed_id, &mut root.children[i], i);
126    true
127}
128
129/// Bottom-right anchored stack. Uses a custom layout function that
130/// pulls the *root* (viewport) rect via `rect_of_id("root")` and
131/// places each card at the bottom-right corner, stacking newest at
132/// the bottom. This makes the layer immune to whatever flow
133/// (`column` / `row` / overlay) the user picked at root — it always
134/// floats over the entire viewport, like a real toast notification.
135fn toast_stack(cards: Vec<El>) -> El {
136    El::new(Kind::Custom("toast_stack"))
137        .children(cards)
138        .fill_size()
139        .layout(|ctx| {
140            let viewport = (ctx.rect_of_id)("root").unwrap_or(ctx.container);
141            let pad = tokens::SPACE_4;
142            let gap = tokens::SPACE_2;
143            let mut rects = Vec::with_capacity(ctx.children.len());
144            // Newest toast (last in `children`) renders at the bottom;
145            // earlier toasts pile upward above it.
146            let mut bottom = viewport.bottom() - pad;
147            for c in ctx.children.iter().rev() {
148                let (w, h) = (ctx.measure)(c);
149                let x = viewport.right() - w - pad;
150                rects.push(Rect::new(x, bottom - h, w, h));
151                bottom -= h + gap;
152            }
153            rects.reverse();
154            rects
155        })
156}
157
158/// One toast card — surface with level-coloured leading bar, message
159/// text, and a dismiss button keyed `toast-dismiss-{id}` so the
160/// runtime can recognize and remove it on click. The leading bar is
161/// `Align::Stretch` so it fills the card's vertical extent.
162fn toast_card(t: &Toast) -> El {
163    let accent = level_accent(t.level);
164    let lead = El::new(Kind::Group)
165        .width(Size::Fixed(3.0))
166        .height(Size::Fill(1.0))
167        .fill(accent)
168        .radius(tokens::RADIUS_SM);
169    let body = El::new(Kind::Text)
170        .text(t.message.clone())
171        .text_role(TextRole::Body)
172        .text_color(tokens::FOREGROUND)
173        .text_wrap(TextWrap::Wrap)
174        .width(Size::Fill(1.0));
175    let dismiss = button("×")
176        .key(format!("toast-dismiss-{}", t.id))
177        .secondary();
178
179    El::new(Kind::Custom("toast_card"))
180        .style_profile(StyleProfile::Surface)
181        .surface_role(SurfaceRole::Popover)
182        .axis(Axis::Row)
183        .align(Align::Stretch)
184        .gap(tokens::SPACE_2)
185        .padding(tokens::SPACE_3)
186        .fill(tokens::POPOVER)
187        .stroke(tokens::BORDER)
188        .radius(tokens::RADIUS_MD)
189        .shadow(tokens::SHADOW_MD)
190        .width(Size::Fixed(360.0))
191        .height(Size::Hug)
192        .children([lead, body, dismiss])
193}
194
195fn level_accent(level: ToastLevel) -> Color {
196    match level {
197        ToastLevel::Default => tokens::INPUT,
198        ToastLevel::Success => tokens::SUCCESS,
199        ToastLevel::Warning => tokens::WARNING,
200        ToastLevel::Error => tokens::DESTRUCTIVE,
201        ToastLevel::Info => tokens::INFO,
202    }
203}
204
205/// Parse the toast id out of a `toast-dismiss-{id}` button key.
206/// Returns `None` for keys that don't match the toast-dismiss
207/// convention. Used by the runtime to intercept dismiss clicks.
208pub fn parse_dismiss_key(key: &str) -> Option<u64> {
209    key.strip_prefix("toast-dismiss-")
210        .and_then(|rest| rest.parse::<u64>().ok())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::layout::{assign_ids, layout};
217
218    #[test]
219    fn synthesize_appends_layer_per_active_toast() {
220        let mut tree = crate::stack(std::iter::empty::<El>());
221        let mut state = UiState::new();
222        let now = Instant::now();
223        state.push_toast(ToastSpec::success("Saved"), now);
224        state.push_toast(ToastSpec::error("Failed"), now);
225
226        assign_ids(&mut tree);
227        let pending = synthesize_toasts(&mut tree, &mut state, now);
228        assert!(pending, "active toasts → caller should request redraw");
229        let stack = tree.children.last().expect("toast_stack appended to root");
230        assert!(matches!(stack.kind, Kind::Custom("toast_stack")));
231        assert_eq!(stack.children.len(), 2);
232    }
233
234    #[test]
235    fn synthesize_drops_expired_toasts() {
236        let mut tree = crate::stack(std::iter::empty::<El>());
237        let mut state = UiState::new();
238        let t0 = Instant::now();
239        // Old TTL: already gone. New TTL: still fresh.
240        state.push_toast(
241            ToastSpec::info("old").with_ttl(Duration::from_millis(10)),
242            t0,
243        );
244        state.push_toast(ToastSpec::info("new").with_ttl(Duration::from_secs(60)), t0);
245        let later = t0 + Duration::from_secs(1);
246        let pending = synthesize_toasts(&mut tree, &mut state, later);
247        assert!(pending);
248        assert_eq!(state.toast.queue.len(), 1, "expired toast dropped");
249        assert_eq!(state.toast.queue[0].message, "new");
250    }
251
252    #[test]
253    fn synthesize_returns_false_when_no_toasts() {
254        let mut tree = crate::stack(std::iter::empty::<El>());
255        let mut state = UiState::new();
256        let pending = synthesize_toasts(&mut tree, &mut state, Instant::now());
257        assert!(!pending);
258        assert!(tree.children.is_empty());
259    }
260
261    #[test]
262    fn parse_dismiss_key_round_trip() {
263        assert_eq!(parse_dismiss_key("toast-dismiss-7"), Some(7));
264        assert_eq!(parse_dismiss_key("toast-dismiss-0"), Some(0));
265        assert_eq!(parse_dismiss_key("save"), None);
266        assert_eq!(parse_dismiss_key("toast-dismiss-abc"), None);
267    }
268
269    #[test]
270    fn toast_stack_layer_lays_out_at_root() {
271        let mut tree = crate::stack(std::iter::empty::<El>()).fill_size();
272        let mut state = UiState::new();
273        let now = Instant::now();
274        state.push_toast(ToastSpec::default("hello"), now);
275        synthesize_toasts(&mut tree, &mut state, now);
276        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
277        // The toast_stack layer occupies the full viewport so its
278        // children can be bottom-right anchored.
279        let stack = tree.children.last().unwrap();
280        let r = state.rect(&stack.computed_id);
281        assert!((r.w - 800.0).abs() < 0.01);
282        assert!((r.h - 600.0).abs() < 0.01);
283    }
284}