damascene-core 0.6.0

Damascene — backend-agnostic UI library core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Runtime-synthesized toast notifications.
//!
//! Apps push toasts via [`crate::App::drain_toasts`]; the runtime stamps each
//! with a monotonic id + an expiry, queues it in [`UiState`],
//! and synthesizes a `Kind::Custom("toast_stack")` floating layer at
//! the El root each frame. The layer is bottom-right anchored, hit-test
//! transparent except for the per-toast dismiss button (which the
//! runtime intercepts in `pointer_up` and removes the toast on).
//!
//! This mirrors [`crate::tooltip`]: tree is the source of truth at
//! frame end, but the *triggers* (hover for tooltips, fire-and-forget
//! for toasts) are runtime-managed because composing them by hand each
//! frame would be a lot of per-app plumbing for a behaviour every UI
//! shares.

// Lock in full per-item documentation for this module (issue #73).
#![warn(missing_docs)]

use std::time::Duration;

use web_time::Instant;

use crate::state::UiState;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::button::button;

/// Default time a toast stays on screen before auto-dismissing.
/// Matches the shadcn / Sonner default. Apps override per-toast via
/// [`ToastSpec::with_ttl`].
pub const DEFAULT_TOAST_TTL: Duration = Duration::from_secs(4);

/// Maximum number of toasts rendered at once — the newest entries in
/// the queue. Matches Sonner's `visibleToasts` default. Older toasts
/// stay queued (and keep aging toward their TTL) and surface as the
/// visible ones expire or are dismissed, so a burst can't stack cards
/// off the top of the viewport.
pub const MAX_VISIBLE_TOASTS: usize = 3;

/// Hard cap on the queue itself. [`UiState::push_toast`] drops the
/// oldest queued toast once the queue is full, bounding memory and the
/// per-frame expiry sweep when something pathological (a reconnect
/// loop, say) pushes long-TTL toasts every frame.
pub const MAX_QUEUED_TOASTS: usize = 64;

/// How long a dismissed or expired toast stays mounted while its exit
/// animation (fade + sink) plays. Matches the
/// [`Timing::EASE_STANDARD`](crate::anim::Timing::EASE_STANDARD) tween
/// the exiting card animates with, so the card is removed right as it
/// finishes fading.
pub const TOAST_EXIT_WINDOW: Duration = Duration::from_millis(200);

/// Severity / variant for a toast. Drives the leading icon and the
/// surface accent colour. Mirrors the shadcn `<Toast variant="...">`
/// vocabulary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastLevel {
    /// Neutral notification with no severity semantics.
    Default,
    /// Positive confirmation — the operation completed.
    Success,
    /// Something needs attention but didn't fail.
    Warning,
    /// Operation failed; uses the destructive accent.
    Error,
    /// Informational notice.
    Info,
}

/// What the app produces from [`crate::App::drain_toasts`]. The
/// runtime stamps an `id` + computes `expires_at` when it queues
/// the toast into [`UiState`]'s runtime queue.
#[derive(Clone, Debug)]
pub struct ToastSpec {
    /// Severity / variant — drives the card's leading accent bar.
    pub level: ToastLevel,
    /// Body text shown in the card (wraps).
    pub message: String,
    /// On-screen time before auto-dismissal; the runtime computes
    /// `expires_at = now + ttl` on enqueue.
    pub ttl: Duration,
}

impl ToastSpec {
    /// A spec at `level` with the default TTL ([`DEFAULT_TOAST_TTL`]).
    pub fn new(level: ToastLevel, message: impl Into<String>) -> Self {
        Self {
            level,
            message: message.into(),
            ttl: DEFAULT_TOAST_TTL,
        }
    }
    /// A [`ToastLevel::Default`] toast with the default TTL.
    pub fn default(message: impl Into<String>) -> Self {
        Self::new(ToastLevel::Default, message)
    }
    /// A [`ToastLevel::Success`] toast with the default TTL.
    pub fn success(message: impl Into<String>) -> Self {
        Self::new(ToastLevel::Success, message)
    }
    /// A [`ToastLevel::Warning`] toast with the default TTL.
    pub fn warning(message: impl Into<String>) -> Self {
        Self::new(ToastLevel::Warning, message)
    }
    /// A [`ToastLevel::Error`] toast with the default TTL.
    pub fn error(message: impl Into<String>) -> Self {
        Self::new(ToastLevel::Error, message)
    }
    /// A [`ToastLevel::Info`] toast with the default TTL.
    pub fn info(message: impl Into<String>) -> Self {
        Self::new(ToastLevel::Info, message)
    }
    /// Builder-style: override the on-screen time
    /// ([`DEFAULT_TOAST_TTL`] when not called).
    pub fn with_ttl(mut self, ttl: Duration) -> Self {
        self.ttl = ttl;
        self
    }
}

/// A queued toast — id stamped by the runtime on enqueue, used both
/// as the dismiss-button suffix and to drop the right entry when
/// the X is clicked or the TTL elapses.
#[derive(Clone, Debug)]
pub struct Toast {
    /// Monotonic id stamped by the runtime on enqueue; suffixes the
    /// dismiss-button key (`toast-dismiss-{id}`).
    pub id: u64,
    /// Severity carried over from the [`ToastSpec`].
    pub level: ToastLevel,
    /// Body text carried over from the [`ToastSpec`].
    pub message: String,
    /// When the toast auto-dismisses: enqueue time + the spec's TTL.
    pub expires_at: Instant,
    /// Set by [`UiState::dismiss_toast`] — the next synthesis pass
    /// starts the exit animation instead of the toast waiting out its
    /// TTL. (A flag rather than an eager removal so dismissal and
    /// expiry share one exit path.)
    pub dismissed: bool,
    /// When the exit window opened (first synthesis pass that saw the
    /// toast dismissed or expired). The card stays mounted — fading
    /// and sinking — until [`TOAST_EXIT_WINDOW`] elapses, then leaves
    /// the queue.
    pub exit_started: Option<Instant>,
}

/// Runtime synthesis pass: drop expired toasts, then append a
/// floating `toast_stack` layer if any remain. Called from
/// `prepare_layout` after [`crate::tooltip::synthesize_tooltip`].
/// Returns `true` while any toast is pending so the host keeps the
/// redraw loop alive long enough to drop the next-to-expire toast.
///
/// Only the newest [`MAX_VISIBLE_TOASTS`] render; older entries stay
/// queued and surface as the visible ones expire or are dismissed.
///
/// **Root precondition:** the synthesized layer is appended as a
/// sibling of whatever the app returned from [`crate::App::build`].
/// For it to overlay (rather than compete for flex space) the root
/// must be an `Axis::Overlay` container — typically `overlays(main,
/// [])`, which is the same convention apps use for user-composed
/// popovers and modals. Debug builds panic on a non-overlay root.
pub fn synthesize_toasts(root: &mut El, ui_state: &mut UiState, now: Instant) -> bool {
    // Expiry and dismissal both funnel into the exit window: the card
    // stays mounted for TOAST_EXIT_WINDOW animating toward its exit
    // pose, then leaves the queue.
    for t in ui_state.toast.queue.iter_mut() {
        if t.exit_started.is_none() && (t.dismissed || t.expires_at <= now) {
            t.exit_started = Some(now);
        }
    }
    ui_state.toast.queue.retain(|t| {
        t.exit_started
            .is_none_or(|s| now.duration_since(s) < TOAST_EXIT_WINDOW)
    });
    if ui_state.toast.queue.is_empty() {
        return false;
    }
    debug_assert_eq!(
        root.axis,
        Axis::Overlay,
        "synthesize_toasts: root must be an Axis::Overlay container so the toast \
         stack overlays the main view. Wrap your `App::build` return value in \
         `overlays(main, [])`. Got axis = {:?}",
        root.axis,
    );
    let visible_from = ui_state
        .toast
        .queue
        .len()
        .saturating_sub(MAX_VISIBLE_TOASTS);
    let cards: Vec<El> = ui_state.toast.queue[visible_from..]
        .iter()
        .map(toast_card)
        .collect();
    root.children.push(toast_stack(cards));
    // Assign computed_ids to the pushed layer in-place so the
    // subsequent `layout_post_assign` doesn't have to re-walk the
    // whole tree. Pairs with `RunnerCore::prepare_layout`'s
    // skip-the-second-id-walk flow.
    let i = root.children.len() - 1;
    crate::layout::assign_id_appended(&root.computed_id, &mut root.children[i], i);
    true
}

/// Bottom-right anchored stack. Uses a custom layout function that
/// pulls the *root* (viewport) rect via `rect_of_id("root")` and
/// places each card at the bottom-right corner, stacking newest at
/// the bottom. This makes the layer immune to whatever flow
/// (`column` / `row` / overlay) the user picked at root — it always
/// floats over the entire viewport, like a real toast notification.
fn toast_stack(cards: Vec<El>) -> El {
    El::new(Kind::Custom("toast_stack"))
        .children(cards)
        .fill_size()
        .layout(|ctx| {
            let viewport = (ctx.rect_of_id)("root").unwrap_or(ctx.container);
            let pad = tokens::SPACE_4;
            let gap = tokens::SPACE_2;
            let mut rects = Vec::with_capacity(ctx.children.len());
            // Newest toast (last in `children`) renders at the bottom;
            // earlier toasts pile upward above it.
            let mut bottom = viewport.bottom() - pad;
            for c in ctx.children.iter().rev() {
                let (w, h) = (ctx.measure)(c);
                let x = viewport.right() - w - pad;
                rects.push(Rect::new(x, bottom - h, w, h));
                bottom -= h + gap;
            }
            rects.reverse();
            rects
        })
}

/// One toast card — surface with level-coloured leading bar, message
/// text, and a dismiss button keyed `toast-dismiss-{id}` so the
/// runtime can recognize and remove it on click. The leading bar is
/// `Align::Stretch` so it fills the card's vertical extent.
fn toast_card(t: &Toast) -> El {
    let accent = level_accent(t.level);
    let lead = El::new(Kind::Group)
        .width(Size::Fixed(3.0))
        .height(Size::Fill(1.0))
        .fill(accent)
        .radius(tokens::RADIUS_SM);
    let body = El::new(Kind::Text)
        .text(t.message.clone())
        .text_role(TextRole::Body)
        .text_color(tokens::FOREGROUND)
        .text_wrap(TextWrap::Wrap)
        .width(Size::Fill(1.0));
    let dismiss = button("×")
        .key(format!("toast-dismiss-{}", t.id))
        .secondary();

    // Keyed by id so animation trackers survive sibling removal —
    // an index-derived identity would shift when an older toast
    // leaves the stack, replaying entrances (and snapping exits).
    let card = El::new(Kind::Custom("toast_card"))
        .key(format!("toast-{}", t.id))
        .style_profile(StyleProfile::Surface)
        .surface_role(SurfaceRole::Popover)
        .axis(Axis::Row)
        .align(Align::Stretch)
        .gap(tokens::SPACE_2)
        .padding(tokens::SPACE_3)
        .fill(tokens::POPOVER)
        .stroke(tokens::BORDER)
        .radius(tokens::RADIUS_MD)
        .shadow(tokens::SHADOW_MD)
        // Sonner-style entrance: rise in from below with a fade.
        .enter_transition(
            crate::anim::EnterTransition::fade()
                .with_slide(0.0, 16.0)
                .with_timing(crate::anim::Timing::SPRING_STANDARD),
        )
        .width(Size::Fixed(360.0))
        .height(Size::Hug)
        .children([lead, body, dismiss]);

    // Exit: build toward the exit pose (transparent, sunk back below)
    // under a tween matched to TOAST_EXIT_WINDOW. The app-prop
    // trackers ease from the card's current rest values, so a toast
    // dismissed mid-entrance turns around smoothly.
    if t.exit_started.is_some() {
        card.opacity(0.0)
            .translate(0.0, 16.0)
            .animate(crate::anim::Timing::EASE_STANDARD)
    } else {
        card
    }
}

fn level_accent(level: ToastLevel) -> Color {
    match level {
        ToastLevel::Default => tokens::INPUT,
        ToastLevel::Success => tokens::SUCCESS,
        ToastLevel::Warning => tokens::WARNING,
        ToastLevel::Error => tokens::DESTRUCTIVE,
        ToastLevel::Info => tokens::INFO,
    }
}

/// Parse the toast id out of a `toast-dismiss-{id}` button key.
/// Returns `None` for keys that don't match the toast-dismiss
/// convention. Used by the runtime to intercept dismiss clicks.
pub fn parse_dismiss_key(key: &str) -> Option<u64> {
    key.strip_prefix("toast-dismiss-")
        .and_then(|rest| rest.parse::<u64>().ok())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::layout::{assign_ids, layout};

    #[test]
    fn synthesize_appends_layer_per_active_toast() {
        let mut tree = crate::stack(std::iter::empty::<El>());
        let mut state = UiState::new();
        let now = Instant::now();
        state.push_toast(ToastSpec::success("Saved"), now);
        state.push_toast(ToastSpec::error("Failed"), now);

        assign_ids(&mut tree);
        let pending = synthesize_toasts(&mut tree, &mut state, now);
        assert!(pending, "active toasts → caller should request redraw");
        let stack = tree.children.last().expect("toast_stack appended to root");
        assert!(matches!(stack.kind, Kind::Custom("toast_stack")));
        assert_eq!(stack.children.len(), 2);
    }

    #[test]
    fn expired_toast_exits_through_the_window_not_instantly() {
        let mut tree = crate::stack(std::iter::empty::<El>());
        let mut state = UiState::new();
        let t0 = Instant::now();
        // Old TTL: already gone. New TTL: still fresh.
        state.push_toast(
            ToastSpec::info("old").with_ttl(Duration::from_millis(10)),
            t0,
        );
        state.push_toast(ToastSpec::info("new").with_ttl(Duration::from_secs(60)), t0);
        assign_ids(&mut tree);

        // First pass past the TTL: the expired toast stays mounted,
        // entering its exit window rather than vanishing.
        let later = t0 + Duration::from_secs(1);
        assert!(synthesize_toasts(&mut tree, &mut state, later));
        assert_eq!(state.toast.queue.len(), 2, "expired toast exits, not drops");
        assert_eq!(state.toast.queue[0].exit_started, Some(later));

        // The exiting card builds toward the exit pose under a tween;
        // the live one keeps its rest pose.
        let stack = tree.children.last().expect("toast_stack appended");
        let exiting = &stack.children[0];
        assert_eq!(exiting.opacity, 0.0);
        assert_eq!(exiting.translate, (0.0, 16.0));
        assert!(exiting.animate_timing().is_some());
        let live = &stack.children[1];
        assert_eq!(live.opacity, 1.0);
        assert!(live.animate_timing().is_none());

        // Once the window elapses, the toast leaves the queue.
        let mut tree2 = crate::stack(std::iter::empty::<El>());
        assign_ids(&mut tree2);
        let after = later + TOAST_EXIT_WINDOW + Duration::from_millis(10);
        assert!(synthesize_toasts(&mut tree2, &mut state, after));
        assert_eq!(state.toast.queue.len(), 1, "exit window elapsed");
        assert_eq!(state.toast.queue[0].message, "new");
    }

    #[test]
    fn dismissed_toast_exits_through_the_window() {
        let mut tree = crate::stack(std::iter::empty::<El>());
        let mut state = UiState::new();
        let t0 = Instant::now();
        state.push_toast(ToastSpec::info("bye").with_ttl(Duration::from_secs(60)), t0);
        let id = state.toast.queue[0].id;
        assign_ids(&mut tree);

        state.dismiss_toast(id);
        assert_eq!(state.toast.queue.len(), 1, "dismiss marks, doesn't remove");
        assert!(state.toast.queue[0].dismissed);

        let t1 = t0 + Duration::from_millis(50);
        assert!(synthesize_toasts(&mut tree, &mut state, t1));
        assert_eq!(state.toast.queue[0].exit_started, Some(t1));

        let mut tree2 = crate::stack(std::iter::empty::<El>());
        assign_ids(&mut tree2);
        let t2 = t1 + TOAST_EXIT_WINDOW + Duration::from_millis(10);
        assert!(!synthesize_toasts(&mut tree2, &mut state, t2));
        assert!(state.toast.queue.is_empty());
    }

    #[test]
    fn toast_cards_are_keyed_by_id() {
        // Stable identity keeps animation trackers alive when an older
        // sibling leaves the stack mid-exit; index-derived ids would
        // shift and replay entrances.
        let mut tree = crate::stack(std::iter::empty::<El>());
        let mut state = UiState::new();
        let now = Instant::now();
        state.push_toast(ToastSpec::info("a"), now);
        state.push_toast(ToastSpec::info("b"), now);
        assign_ids(&mut tree);
        synthesize_toasts(&mut tree, &mut state, now);
        let stack = tree.children.last().expect("toast_stack appended");
        let keys: Vec<_> = stack.children.iter().map(|c| c.key.as_deref()).collect();
        let id0 = state.toast.queue[0].id;
        let id1 = state.toast.queue[1].id;
        assert_eq!(
            keys,
            vec![
                Some(format!("toast-{id0}")).as_deref(),
                Some(format!("toast-{id1}")).as_deref(),
            ]
        );
    }

    #[test]
    fn synthesize_returns_false_when_no_toasts() {
        let mut tree = crate::stack(std::iter::empty::<El>());
        let mut state = UiState::new();
        let pending = synthesize_toasts(&mut tree, &mut state, Instant::now());
        assert!(!pending);
        assert!(tree.children.is_empty());
    }

    #[test]
    fn only_newest_toasts_render_beyond_the_visible_cap() {
        let mut tree = crate::stack(std::iter::empty::<El>());
        let mut state = UiState::new();
        let now = Instant::now();
        for i in 0..MAX_VISIBLE_TOASTS + 2 {
            state.push_toast(ToastSpec::info(format!("t{i}")), now);
        }
        assign_ids(&mut tree);
        synthesize_toasts(&mut tree, &mut state, now);
        // The full queue is retained — hidden toasts surface as the
        // visible ones expire — but only the newest render.
        assert_eq!(state.toast.queue.len(), MAX_VISIBLE_TOASTS + 2);
        let stack = tree.children.last().expect("toast_stack appended");
        assert_eq!(stack.children.len(), MAX_VISIBLE_TOASTS);
        let first_card = &stack.children[0];
        let body = &first_card.children[1];
        assert_eq!(
            body.text.as_deref(),
            Some("t2"),
            "oldest rendered card should be the first beyond the hidden ones"
        );
    }

    #[test]
    fn push_toast_drops_oldest_once_the_queue_is_full() {
        let mut state = UiState::new();
        let now = Instant::now();
        for i in 0..MAX_QUEUED_TOASTS + 5 {
            state.push_toast(
                ToastSpec::info(format!("t{i}")).with_ttl(Duration::from_secs(600)),
                now,
            );
        }
        assert_eq!(state.toast.queue.len(), MAX_QUEUED_TOASTS);
        assert_eq!(
            state.toast.queue[0].message, "t5",
            "oldest entries dropped first"
        );
        assert_eq!(
            state.toast.queue.last().unwrap().message,
            format!("t{}", MAX_QUEUED_TOASTS + 4),
        );
    }

    #[test]
    fn parse_dismiss_key_round_trip() {
        assert_eq!(parse_dismiss_key("toast-dismiss-7"), Some(7));
        assert_eq!(parse_dismiss_key("toast-dismiss-0"), Some(0));
        assert_eq!(parse_dismiss_key("save"), None);
        assert_eq!(parse_dismiss_key("toast-dismiss-abc"), None);
    }

    #[test]
    fn toast_stack_layer_lays_out_at_root() {
        let mut tree = crate::stack(std::iter::empty::<El>()).fill_size();
        let mut state = UiState::new();
        let now = Instant::now();
        state.push_toast(ToastSpec::default("hello"), now);
        synthesize_toasts(&mut tree, &mut state, now);
        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
        // The toast_stack layer occupies the full viewport so its
        // children can be bottom-right anchored.
        let stack = tree.children.last().unwrap();
        let r = stack.computed_rect;
        assert!((r.w - 800.0).abs() < 0.01);
        assert!((r.h - 600.0).abs() < 0.01);
    }
}