Skip to main content

teksilo_widgets/
toast.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Toast notification — stackable, action-rich, severity-aware floating
5//! notification (the "upgrade path" from [`Snackbar`](crate::snackbar)).
6//!
7//! Distinct from siblings:
8//! - [`Snackbar`](crate::snackbar::Snackbar) — single-instance, message-only.
9//!   Calling `present_snackbar` dismisses all other overlays first.
10//! - [`Banner`](crate::banner::Banner) — persistent inline strip, not a floating
11//!   overlay.
12//! - [`MessageBox`](crate::message_box::MessageBox) — modal dialog. Blocks
13//!   interaction with the rest of the UI.
14//!
15//! A `Toast` is built with one of the four severity constructors
16//! (`info` / `success` / `warning` / `error`) plus a `loading` variant,
17//! configured via builder methods, and presented with
18//! `ctx.show_toast(toast)` (see
19//! [`toast::ext::EventContextToastExt`](crate::toast::ext::EventContextToastExt))
20//! or `toast.present(ctx)`. A [`ToastHost`]
21//! installed via `TeksiloAppBuilder.install_toast(opts)` from the `teksilo`
22//! umbrella accepts the request, picks a free slot from its pool, and
23//! mounts a [`ToastSurface`] at the
24//! configured viewport corner using the
25//! [`OverlayPlacement::ViewportCorner`](teksilo_core::overlay::OverlayPlacement)
26//! variant.
27//!
28//! ```ignore
29//! ctx.show_toast(
30//!     Toast::warning(tr!(unsaved_changes()))
31//!         .body(tr!(close_anyway_question()))
32//!         .action(ToastAction::primary(tr!(save()), |c| c.send_intent(AppIntent::Save)))
33//!         .action(ToastAction::new(tr!(discard()), |c| c.send_intent(AppIntent::Discard)))
34//! );
35//! ```
36
37pub mod body;
38pub mod ext;
39pub mod host;
40pub mod registry;
41pub mod surface;
42
43use std::cell::Cell;
44use std::rc::Rc;
45use std::time::Duration;
46
47use teksilo_core::widget::{EventContext, Widget};
48use teksilo_core::window::TeksiloWindowId;
49
50pub use body::TOAST_BODY_COLLAPSED_LINES;
51pub use ext::EventContextToastExt;
52pub use host::{ToastHost, ToastInstallOptions};
53pub use registry::ToastRegistry;
54pub use surface::ToastSurface;
55pub use teksilo_core::styles::{ToastPriority, ToastStyleConfig};
56
57/// Toast severity — re-export of `BannerSeverity` so apps that mix
58/// `Banner` and `Toast` share one severity vocabulary. The same
59/// `severity.surface()` / `severity.glyph_color(theme)` helpers apply.
60pub use teksilo_core::styles::BannerSeverity as ToastSeverity;
61use teksilo_i18n::LocalizedString;
62
63/// Default auto-dismiss duration when the caller does not override
64/// it (matches IntelliJ `BALLOON` and Material Snackbar maximum).
65pub const DEFAULT_TOAST_AUTO_DISMISS: Duration = Duration::from_secs(10);
66
67// =====================================================================
68// ToastDismissCause
69// =====================================================================
70
71/// Why a toast was dismissed — delivered to the `on_dismiss` callback.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum ToastDismissCause {
74    /// `auto_dismiss_after` reached zero (timer expired naturally).
75    Timeout,
76    /// A `ToastAction` with `closes_toast(true)` (the default) fired.
77    ActionInvoked,
78    /// The user clicked the close (X) button.
79    CloseClicked,
80    /// The user pressed Escape while focus was inside the toast.
81    EscapePressed,
82    /// `ToastHandle::dismiss` was called from app code.
83    Programmatic,
84    /// The host's window is being torn down.
85    HostShutdown,
86    /// The host's slot pool was at `max_visible` and this toast was
87    /// dropped (Normal priority overflow) or was evicted by a
88    /// higher-priority arrival. Reported synthetically so `on_dismiss`
89    /// always fires once per toast — apps that track outstanding
90    /// toasts via the callback don't leak.
91    SlotPoolFull,
92}
93
94// =====================================================================
95// Routing — ToastAudience / ToastRoute
96// =====================================================================
97
98/// Opaque per-app routing token. teksilo has no notion of what an
99/// "audience" means to the host app (a document, a project, a user
100/// session, …) — it only ever compares and hashes this value. Apps
101/// mint their own tokens (typically one per open document/window
102/// group) via [`ToastAudience::new`] and pass the same value to
103/// `Toast::target(...)` and `ToastRegistry::set_window_audience(...)`
104/// to link the two sides of the routing decision.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
106pub struct ToastAudience(u64);
107
108impl ToastAudience {
109    /// Construct a token from an app-chosen `u64`. The app owns the
110    /// meaning entirely — teksilo never inspects the value beyond
111    /// equality/hash.
112    pub fn new(id: u64) -> Self {
113        Self(id)
114    }
115
116    /// The raw numeric value, for debugging/serialization by the app.
117    pub fn raw(&self) -> u64 {
118        self.0
119    }
120}
121
122/// Resolved delivery target for a toast (and, mirrored, its archived
123/// `NotificationEntry`).
124///
125/// Three levels, from narrowest to widest:
126/// - `Window` — exactly the window that presented the toast. This is
127///   the default when a `Toast` carries no explicit `.target()` /
128///   `.broadcast()` and was presented through a real `EventContext`
129///   (i.e. `ctx.show_toast(...)` / `toast.present(ctx)` from an actual
130///   input handler) — see `EventContextToastExt::show_toast`.
131/// - `Audience` — every window currently assigned the given
132///   [`ToastAudience`] via `ToastRegistry::set_window_audience`.
133/// - `Broadcast` — every window, unconditionally. Also the fallback
134///   when a toast is enqueued with no window AND no explicit target
135///   (e.g. `ToastRegistry::show_settings_write_failed`, which fires
136///   from a background `AppEvent` observer with no `EventContext` at
137///   all) — an app-wide message with nothing narrower to route by.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
139pub enum ToastRoute {
140    /// Delivered only to the window with this id. Never publicly
141    /// constructible from a `Toast` builder — only the framework
142    /// stamps this, from a real `EventContext::window()` at present
143    /// time — so an app can't accidentally fabricate a route to a
144    /// window it doesn't own.
145    Window(TeksiloWindowId),
146    /// Delivered to every window currently assigned this audience.
147    Audience(ToastAudience),
148    /// Delivered to every window, unconditionally.
149    Broadcast,
150}
151
152// =====================================================================
153// ToastAction
154// =====================================================================
155
156/// How a [`ToastAction`] should be rendered inside the toast surface.
157#[derive(Debug, Clone, Default)]
158pub enum ToastActionStyle {
159    /// JetBrains-style hyperlink. Rendered inline with the body row.
160    /// Default — minimal visual weight, scales to many actions.
161    #[default]
162    Link,
163    /// Material / Windows-style button. Rendered in a dedicated row
164    /// below the body. Use for primary calls-to-action ("Retry",
165    /// "Save", "Discard").
166    Button {
167        /// Variant passed to the underlying `Button`. Filled for
168        /// primaries, Plain / Tinted for secondaries.
169        variant: crate::button::ButtonVariant,
170    },
171}
172
173/// Type-erased callback for a [`ToastAction`]. `Fn` (not `FnMut`) so
174/// the same callback can be wrapped in an `Rc` and dispatched from
175/// multiple paths (tap, keyboard, AT custom action).
176pub type ToastActionCallback = Rc<dyn Fn(&mut EventContext)>;
177
178/// One actionable element inside a [`Toast`] — a button or hyperlink
179/// the user can click to drive a domain action.
180pub struct ToastAction {
181    label: LocalizedString,
182    on_invoke: ToastActionCallback,
183    style: ToastActionStyle,
184    closes_toast: bool,
185    shortcut_id: Option<String>,
186    tooltip: Option<LocalizedString>,
187}
188
189impl ToastAction {
190    /// Build an action with the default `Link` style and
191    /// `closes_toast = true` (IntelliJ "expiring action" semantics).
192    pub fn new(
193        label: impl Into<LocalizedString>,
194        on_invoke: impl Fn(&mut EventContext) + 'static,
195    ) -> Self {
196        let ls: LocalizedString = label.into();
197        Self {
198            label: ls,
199            on_invoke: Rc::new(on_invoke),
200            style: ToastActionStyle::default(),
201            closes_toast: true,
202            shortcut_id: None,
203            tooltip: None,
204        }
205    }
206
207    /// Shorthand for `ToastAction::new(label, on_invoke).style(Button { Filled })`.
208    /// The visual-weight default for primary calls-to-action.
209    pub fn primary(
210        label: impl Into<LocalizedString>,
211        on_invoke: impl Fn(&mut EventContext) + 'static,
212    ) -> Self {
213        Self::new(label, on_invoke).style(ToastActionStyle::Button {
214            variant: crate::button::ButtonVariant::Filled,
215        })
216    }
217
218    /// Shorthand for the destructive button variant — red-tinted for
219    /// confirm-style "Delete" / "Discard" actions.
220    pub fn destructive(
221        label: impl Into<LocalizedString>,
222        on_invoke: impl Fn(&mut EventContext) + 'static,
223    ) -> Self {
224        Self::new(label, on_invoke).style(ToastActionStyle::Button {
225            variant: crate::button::ButtonVariant::Destructive,
226        })
227    }
228
229    /// Override the action's visual style. Default is `Link`.
230    pub fn style(mut self, style: ToastActionStyle) -> Self {
231        self.style = style;
232        self
233    }
234
235    /// Whether invoking this action also dismisses the toast. Default
236    /// is `true` — matches IntelliJ's "expiring action" semantics.
237    /// Set to `false` for actions that toggle state without closing
238    /// (e.g. "Show details" disclosure inside a sticky toast).
239    pub fn closes_toast(mut self, closes: bool) -> Self {
240        self.closes_toast = closes;
241        self
242    }
243
244    /// Associate the action with a registered `Shortcut` id. Two
245    /// effects: the keystroke label is shown as a chip on the action,
246    /// and the archived form of this action (in
247    /// [`NotificationLog`](crate::notification::log::NotificationLog))
248    /// is re-invokable by name through the existing Intent
249    /// dispatcher.
250    pub fn shortcut_id(mut self, id: impl Into<String>) -> Self {
251        self.shortcut_id = Some(id.into());
252        self
253    }
254
255    /// Optional tooltip text shown when the pointer hovers the action.
256    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
257        self.tooltip = Some(text.into());
258        self
259    }
260
261    /// Resolve the action label to a plain string using the current locale.
262    pub fn label(&self) -> String {
263        self.label.resolve_now()
264    }
265    /// Return the action's rendering style (link vs button variant).
266    pub fn style_ref(&self) -> &ToastActionStyle {
267        &self.style
268    }
269    /// Return `true` when invoking this action also dismisses the toast.
270    pub fn closes_toast_flag(&self) -> bool {
271        self.closes_toast
272    }
273    /// Return the associated `Shortcut` id, if any.
274    pub fn shortcut_id_ref(&self) -> Option<&str> {
275        self.shortcut_id.as_deref()
276    }
277    /// The action label as a `LocalizedString` (reactive source for
278    /// the rendered Link/Button).
279    pub(crate) fn label_ls(&self) -> LocalizedString {
280        self.label.clone()
281    }
282
283    /// Return the optional tooltip text, if one was set via [`tooltip`](ToastAction::tooltip).
284    pub fn tooltip_ref(&self) -> Option<&LocalizedString> {
285        self.tooltip.as_ref()
286    }
287    /// Clone the invocation callback — cheap because the underlying closure is `Rc`-wrapped.
288    pub fn callback(&self) -> ToastActionCallback {
289        self.on_invoke.clone()
290    }
291}
292
293impl std::fmt::Debug for ToastAction {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        f.debug_struct("ToastAction")
296            .field("label", &self.label)
297            .field("style", &self.style)
298            .field("closes_toast", &self.closes_toast)
299            .field("shortcut_id", &self.shortcut_id)
300            .finish()
301    }
302}
303
304// =====================================================================
305// ToastHandle
306// =====================================================================
307
308/// Returned by [`Toast::present`] (and `ctx.show_toast(toast)`). Cheap
309/// to clone (`Rc<Inner>`). Lets app code dismiss the toast
310/// programmatically or check whether it is still alive.
311///
312/// Dropping the handle does NOT dismiss the toast — toasts have their
313/// own lifecycle managed by the host (timer + manual paths). The
314/// handle is the OPTIONAL "I want to control this toast later" hook.
315#[derive(Clone)]
316pub struct ToastHandle {
317    inner: Rc<ToastHandleInner>,
318}
319
320pub(crate) struct ToastHandleInner {
321    pub(crate) entry_id: u64,
322    /// Marked when the host has dropped the entry (overflow at enqueue
323    /// time, or any dismiss path). Cheap short-circuit for the
324    /// `dismiss` / `is_alive` handle methods so they don't have to
325    /// walk the registry to know "this toast is gone".
326    pub(crate) dismissed: Cell<bool>,
327    /// Back-reference to the registry so the handle can fire dismiss
328    /// requests and check liveness.
329    pub(crate) registry: registry::ToastRegistry,
330}
331
332impl ToastHandle {
333    pub(crate) fn new(inner: ToastHandleInner) -> Self {
334        Self {
335            inner: Rc::new(inner),
336        }
337    }
338
339    /// Stable per-toast id. Two `ToastHandle`s pointing at the same
340    /// underlying toast share the same `entry_id`. The id is unique
341    /// per `ToastRegistry` (per app) — it doesn't survive across app
342    /// restarts.
343    pub fn entry_id(&self) -> u64 {
344        self.inner.entry_id
345    }
346
347    /// Whether the toast is still in the registry's live set (timer
348    /// hasn't expired, user hasn't dismissed, host hasn't shut down).
349    /// Always `false` for overflow-dropped toasts.
350    pub fn is_alive(&self) -> bool {
351        if self.inner.dismissed.get() {
352            return false;
353        }
354        self.inner.registry.is_entry_alive(self.inner.entry_id)
355    }
356
357    /// Programmatically dismiss the toast with cause
358    /// [`ToastDismissCause::Programmatic`]. No-op if the toast is
359    /// already dismissed (timer, user, host shutdown).
360    pub fn dismiss(&self, ctx: &mut EventContext) {
361        if self.inner.dismissed.get() {
362            return;
363        }
364        self.inner.registry.dismiss_entry(
365            self.inner.entry_id,
366            ToastDismissCause::Programmatic,
367            ctx,
368        );
369    }
370}
371
372impl std::fmt::Debug for ToastHandle {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_struct("ToastHandle")
375            .field("entry_id", &self.inner.entry_id)
376            .field("dismissed", &self.inner.dismissed.get())
377            .finish()
378    }
379}
380
381// =====================================================================
382// Toast (the present-able request)
383// =====================================================================
384
385/// Type-erased on_dismiss callback receiving the cause + context.
386pub type ToastDismissCallback = Rc<dyn Fn(ToastDismissCause, &mut EventContext)>;
387
388/// Toast — a present-able request (NOT a `Widget`). Construct with one
389/// of the severity-named constructors, configure via builder methods,
390/// then call `.present(ctx)` or `ctx.show_toast(self)`. Internally the
391/// builder is consumed and its data is moved into a slot on the
392/// installed [`ToastHost`].
393///
394/// See the module docs for the full conceptual overview.
395pub struct Toast {
396    pub(crate) severity: ToastSeverity,
397    pub(crate) title: LocalizedString,
398    pub(crate) body: Option<LocalizedString>,
399    pub(crate) leading: Option<Box<dyn Widget>>,
400    pub(crate) actions: Vec<ToastAction>,
401    pub(crate) auto_dismiss_after: Option<Duration>,
402    pub(crate) priority: ToastPriority,
403    pub(crate) id: Option<String>,
404    pub(crate) on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
405    pub(crate) on_dismiss: Option<ToastDismissCallback>,
406    pub(crate) announcement: Option<LocalizedString>,
407    pub(crate) show_close_button: bool,
408    pub(crate) closable_on_escape: bool,
409    pub(crate) archive: bool,
410    pub(crate) style_override: Option<teksilo_core::styles::SharedToastStyle>,
411    /// Resolved lazily: `None` here means "unset" — `show_toast`
412    /// stamps `Some(ToastRoute::Window(origin))` from the presenting
413    /// `EventContext` when the app didn't call `.target()` /
414    /// `.broadcast()` explicitly. See [`ToastRoute`] for the full
415    /// three-level contract.
416    pub(crate) target: Option<ToastRoute>,
417}
418
419impl std::fmt::Debug for Toast {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        f.debug_struct("Toast")
422            .field("severity", &self.severity)
423            .field("title", &self.title)
424            .field("body", &self.body)
425            .field("priority", &self.priority)
426            .field("id", &self.id)
427            .field("auto_dismiss_after", &self.auto_dismiss_after)
428            .field("actions_count", &self.actions.len())
429            .field("target", &self.target)
430            .finish()
431    }
432}
433
434impl Toast {
435    fn build_with_severity(severity: ToastSeverity, title: impl Into<LocalizedString>) -> Self {
436        let ls: LocalizedString = title.into();
437        Self {
438            severity,
439            title: ls,
440            body: None,
441            leading: None,
442            actions: Vec::new(),
443            auto_dismiss_after: Some(DEFAULT_TOAST_AUTO_DISMISS),
444            priority: ToastPriority::Normal,
445            id: None,
446            on_click: None,
447            on_dismiss: None,
448            announcement: None,
449            show_close_button: true,
450            closable_on_escape: true,
451            archive: true,
452            style_override: None,
453            target: None,
454        }
455    }
456
457    // ----- Constructors -----
458
459    /// Info-severity toast (status confirmation, neutral notice).
460    pub fn info(title: impl Into<LocalizedString>) -> Self {
461        Self::build_with_severity(ToastSeverity::Info, title)
462    }
463    /// Success-severity toast ("Saved", "Connected", "Build finished").
464    pub fn success(title: impl Into<LocalizedString>) -> Self {
465        Self::build_with_severity(ToastSeverity::Success, title)
466    }
467    /// Warning-severity toast.
468    pub fn warning(title: impl Into<LocalizedString>) -> Self {
469        Self::build_with_severity(ToastSeverity::Warning, title)
470    }
471    /// Error-severity toast. Defaults to `Live::Assertive`.
472    pub fn error(title: impl Into<LocalizedString>) -> Self {
473        Self::build_with_severity(ToastSeverity::Error, title)
474    }
475    /// Loading-style toast — Info severity with a
476    /// [`Spinner`](crate::spinner::Spinner) as the leading widget.
477    /// Persistent by default; the app calls
478    /// [`ToastHandle::dismiss`] (typically from the operation's
479    /// completion callback) or replaces it with a success/error toast.
480    pub fn loading(title: impl Into<LocalizedString>) -> Self {
481        Self::build_with_severity(ToastSeverity::Info, title)
482            .persistent()
483            .leading(crate::spinner::Spinner::new(16.0))
484    }
485
486    // ----- _literal shims (permanent grep markers for untranslated strings) -----
487
488    // ----- Body content -----
489
490    /// Optional secondary line below the title.
491    pub fn body(mut self, text: impl Into<LocalizedString>) -> Self {
492        let ls: LocalizedString = text.into();
493        self.body = Some(ls);
494        self
495    }
496
497    /// Replace the default severity glyph with a custom leading
498    /// widget (spinner, app icon, avatar). Boxes the widget so the
499    /// toast remains object-safe.
500    pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
501        self.leading = Some(Box::new(widget));
502        self
503    }
504
505    // ----- Actions -----
506
507    /// Append a [`ToastAction`] (link or button) to the toast.
508    pub fn action(mut self, action: ToastAction) -> Self {
509        self.actions.push(action);
510        self
511    }
512    /// Shorthand for appending a filled-button primary action — equivalent to
513    /// `.action(ToastAction::primary(label, on_invoke))`.
514    pub fn primary_action(
515        self,
516        label: impl Into<LocalizedString>,
517        on_invoke: impl Fn(&mut EventContext) + 'static,
518    ) -> Self {
519        self.action(ToastAction::primary(label, on_invoke))
520    }
521
522    // ----- Lifetime -----
523
524    /// Override the auto-dismiss countdown. Pass `Duration::ZERO` for immediate dismissal
525    /// on the next timer tick; call [`persistent`](Toast::persistent) to disable the timer entirely.
526    pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
527        self.auto_dismiss_after = Some(duration);
528        self
529    }
530    /// Disable auto-dismiss — the toast persists until the user
531    /// clicks the close X, invokes a `closes_toast` action, or the
532    /// app calls [`ToastHandle::dismiss`].
533    pub fn persistent(mut self) -> Self {
534        self.auto_dismiss_after = None;
535        self
536    }
537    /// Set the queue priority. `High` / `Urgent` entries evict the oldest `Normal` entry
538    /// when the slot pool is full; `Urgent` also forces `Live::Assertive` regardless of severity.
539    pub fn priority(mut self, priority: ToastPriority) -> Self {
540        self.priority = priority;
541        self
542    }
543
544    // ----- Update-in-place identity -----
545
546    /// Stable identity for the "progress toast updates in place"
547    /// pattern. A subsequent `enqueue` whose `Toast` carries the same
548    /// `id` as a still-live entry mutates that entry's fields
549    /// (severity, title/body, route, …) in place instead of appending
550    /// a new toast — see `ToastRegistry::enqueue`'s update-in-place
551    /// merge for the exact behaviour.
552    ///
553    /// # Hazard: this id must be unique per logical operation, not just per call site
554    ///
555    /// The merge matches on `id` ALONE — no route/window/audience
556    /// check — and then OVERWRITES the existing entry's route with
557    /// the new toast's resolved target. That's intentional: it's what
558    /// lets a progress toast whose audience becomes known partway
559    /// through retarget itself in place. But it also means that if
560    /// TWO DIFFERENT windows (or two different audiences) each
561    /// present a toast using the SAME `id` for what are, to the app,
562    /// two DIFFERENT operations, the second `enqueue` finds the
563    /// first window's still-live entry, mutates its text/severity to
564    /// the second operation's, and steals its route out from under
565    /// it — the first window's toast is not dismissed, not
566    /// callback'd, just silently overwritten and gone, while the
567    /// second window's operation ends up displayed under the wrong
568    /// route besides.
569    ///
570    /// teksilo deliberately does NOT make the dedup key route-aware
571    /// (matching on `(id, route)` together) — that would break the
572    /// intentional retargeting case above. So in a multi-window /
573    /// multi-document app, do not reuse one static string id across
574    /// windows for what is conceptually a per-document (or otherwise
575    /// per-audience) operation — export, delete, save, etc. Fold the
576    /// document/audience identity into the id yourself, e.g.
577    /// `format!("export-{work_id}")` rather than a bare `"export"`
578    /// constant, so two windows running the same *kind* of operation
579    /// on two different documents never collide on one entry.
580    pub fn id(mut self, id: impl Into<String>) -> Self {
581        self.id = Some(id.into());
582        self
583    }
584
585    // ----- Interaction -----
586
587    /// Treat a click on the toast body as a meaningful action — the
588    /// callback fires on tap. Cursor changes to `Pointer` over the body.
589    pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
590        self.on_click = Some(Rc::new(f));
591        self
592    }
593    /// Notification of dismissal. Fires exactly once per toast on any
594    /// dismiss path (timer, action invocation, close click, escape,
595    /// programmatic, host shutdown, slot-pool overflow).
596    pub fn on_dismiss(
597        mut self,
598        f: impl Fn(ToastDismissCause, &mut EventContext) + 'static,
599    ) -> Self {
600        self.on_dismiss = Some(Rc::new(f));
601        self
602    }
603    /// Show or hide the trailing close (×) button. Default `true`.
604    pub fn show_close_button(mut self, show: bool) -> Self {
605        self.show_close_button = show;
606        self
607    }
608    /// Whether pressing Escape while the toast is focused dismisses
609    /// it. Default true. Set to false in apps that have a custom
610    /// Escape-handling story (focus trap, modal-style toast).
611    pub fn closable_on_escape(mut self, allow: bool) -> Self {
612        self.closable_on_escape = allow;
613        self
614    }
615
616    // ----- Accessibility -----
617
618    /// Override the screen-reader announcement text without changing
619    /// the visible title. Useful when the visible title is iconic
620    /// ("3") but the spoken text needs context ("3 unread messages").
621    pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self {
622        let ls: LocalizedString = text.into();
623        self.announcement = Some(ls);
624        self
625    }
626
627    // ----- Archive -----
628
629    /// Whether this toast is added to the persistent archive that
630    /// drives [`NotificationLog`](crate::notification::log::NotificationLog).
631    /// Default `true`. Set `false` for noise-suppressing
632    /// transient notifications like quick "Copied!" feedback.
633    pub fn archive(mut self, archive: bool) -> Self {
634        self.archive = archive;
635        self
636    }
637
638    // ----- Style -----
639
640    /// Override the visual chrome for this toast instance. Takes precedence over the
641    /// theme-wide `style_slots.toast` slot and the built-in `RecipeToastStyle` default.
642    pub fn style(mut self, style: impl teksilo_core::styles::ToastStyle) -> Self {
643        self.style_override = Some(Rc::new(style));
644        self
645    }
646
647    // ----- Routing -----
648
649    /// Route this toast to every window currently assigned `audience`
650    /// (via `ToastRegistry::set_window_audience`), instead of the
651    /// default origin-window. Overrides any previous `.target()` /
652    /// `.broadcast()` call — last setter wins.
653    pub fn target(mut self, audience: ToastAudience) -> Self {
654        self.target = Some(ToastRoute::Audience(audience));
655        self
656    }
657
658    /// Route this toast to every window, unconditionally — for
659    /// genuinely app-wide messages (a data-loss warning, an update
660    /// available notice) rather than one window's concern. Overrides
661    /// any previous `.target()` call — last setter wins.
662    pub fn broadcast(mut self) -> Self {
663        self.target = Some(ToastRoute::Broadcast);
664        self
665    }
666
667    // ----- Present -----
668
669    /// Submit the toast through the installed
670    /// [`ToastHost`]. Equivalent to
671    /// `ctx.show_toast(self)`. Returns a [`ToastHandle`] for
672    /// programmatic control. If `install_toast` was not called the
673    /// returned handle is in the "dropped" state (`is_alive` returns
674    /// `false`) and a one-shot stderr warning fires explaining the omission.
675    pub fn present(self, ctx: &mut EventContext) -> ToastHandle {
676        EventContextToastExt::show_toast(ctx, self)
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use teksilo_i18n::lit;
684
685    #[test]
686    fn severity_constructors_round_trip() {
687        assert_eq!(Toast::info(lit!("x")).severity, ToastSeverity::Info);
688        assert_eq!(Toast::success(lit!("x")).severity, ToastSeverity::Success);
689        assert_eq!(Toast::warning(lit!("x")).severity, ToastSeverity::Warning);
690        assert_eq!(Toast::error(lit!("x")).severity, ToastSeverity::Error);
691    }
692
693    #[test]
694    fn defaults_match_documented_values() {
695        let t = Toast::info(lit!("hello"));
696        assert_eq!(t.auto_dismiss_after, Some(DEFAULT_TOAST_AUTO_DISMISS));
697        assert_eq!(t.priority, ToastPriority::Normal);
698        assert!(t.show_close_button);
699        assert!(t.closable_on_escape);
700        assert!(t.archive);
701        assert!(t.body.is_none());
702        assert!(t.actions.is_empty());
703        assert!(t.on_dismiss.is_none());
704        assert!(t.on_click.is_none());
705    }
706
707    #[test]
708    fn persistent_clears_auto_dismiss() {
709        let t = Toast::error(lit!("boom")).persistent();
710        assert!(t.auto_dismiss_after.is_none());
711    }
712
713    #[test]
714    fn loading_is_info_persistent() {
715        let t = Toast::loading(lit!("Uploading"));
716        assert_eq!(t.severity, ToastSeverity::Info);
717        assert!(
718            t.auto_dismiss_after.is_none(),
719            "loading is persistent by default"
720        );
721        assert!(t.leading.is_some(), "loading sets a Spinner leading widget");
722    }
723
724    #[test]
725    fn action_primary_uses_filled_button() {
726        let a = ToastAction::primary(lit!("Retry"), |_| {}).style(ToastActionStyle::Button {
727            variant: crate::button::ButtonVariant::Filled,
728        });
729        match a.style_ref() {
730            ToastActionStyle::Button {
731                variant: crate::button::ButtonVariant::Filled,
732            } => {}
733            other => panic!("expected Button {{ Filled }}, got {other:?}"),
734        }
735        assert!(a.closes_toast_flag(), "actions close the toast by default");
736    }
737
738    #[test]
739    fn action_closes_toast_can_be_disabled() {
740        let a = ToastAction::new(lit!("Toggle"), |_| {}).closes_toast(false);
741        assert!(!a.closes_toast_flag());
742    }
743
744    // -----------------------------------------------------------------
745    // Registry tests
746    // -----------------------------------------------------------------
747
748    fn fresh_registry() -> ToastRegistry {
749        ToastRegistry::new(host::ToastInstallOptions::default())
750    }
751
752    fn small_registry(max_visible: usize) -> ToastRegistry {
753        ToastRegistry::new(host::ToastInstallOptions {
754            max_visible,
755            ..host::ToastInstallOptions::default()
756        })
757    }
758
759    #[test]
760    fn enqueue_creates_live_entry() {
761        let r = fresh_registry();
762        let (h, _overflow) = r.enqueue(Toast::info(lit!("Saved")));
763        assert!(h.entry_id() > 0);
764        assert_eq!(r.live_count(), 1);
765    }
766
767    #[test]
768    fn enqueue_returns_distinct_ids() {
769        let r = fresh_registry();
770        let (h1, _) = r.enqueue(Toast::info(lit!("a")));
771        let (h2, _) = r.enqueue(Toast::info(lit!("b")));
772        let (h3, _) = r.enqueue(Toast::info(lit!("c")));
773        assert!(h1.entry_id() != h2.entry_id());
774        assert!(h2.entry_id() != h3.entry_id());
775        assert_eq!(r.live_count(), 3);
776    }
777
778    #[test]
779    fn slot_pool_overflow_drops_normal_priority() {
780        let r = small_registry(2);
781        let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
782        let (_h2, _) = r.enqueue(Toast::info(lit!("b")));
783        assert_eq!(r.live_count(), 2);
784        let (h3, overflow) = r.enqueue(Toast::info(lit!("c")));
785        assert_eq!(
786            r.live_count(),
787            2,
788            "third Normal-priority toast must be dropped when pool is full"
789        );
790        // Returned handle is in the "dropped" state — `is_alive` is
791        // the public surface for this check.
792        assert!(!h3.is_alive(), "overflow handle should not be alive");
793        // overflow callback is None because we didn't attach on_dismiss
794        assert!(overflow.is_none());
795    }
796
797    #[test]
798    fn slot_pool_overflow_fires_on_dismiss_for_normal_drop() {
799        use std::cell::Cell;
800        let r = small_registry(2);
801        let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
802        let (_h2, _) = r.enqueue(Toast::info(lit!("b")));
803        let fired = Rc::new(Cell::new(false));
804        let fired_clone = fired.clone();
805        let (_h3, overflow) = r.enqueue(Toast::info(lit!("c")).on_dismiss(move |cause, _ctx| {
806            assert_eq!(cause, ToastDismissCause::SlotPoolFull);
807            fired_clone.set(true);
808        }));
809        // The registry returns the overflow callback to the caller —
810        // the ext's `show_toast` then invokes it synchronously with
811        // its `EventContext`. Simulate that here by just calling it.
812        let (_cause, cb) = overflow.expect("overflow callback present");
813        // We don't have a real EventContext in unit tests, but the
814        // callback signature is `(cause, &mut EventContext)`. Need
815        // to construct one — skip this part; the registry mechanism
816        // (correctly returning the callback) is what we're verifying.
817        let _ = cb;
818        // The actual user-callback invocation is exercised in the
819        // ext + WidgetTree integration tests below.
820        assert!(!fired.get(), "callback fires only when ext invokes it");
821    }
822
823    #[test]
824    fn high_priority_evicts_oldest_normal_when_full() {
825        let r = small_registry(2);
826        let (h_a, _) = r.enqueue(Toast::info(lit!("a")));
827        let (h_b, _) = r.enqueue(Toast::info(lit!("b")));
828        let oldest_normal_id = h_a.entry_id();
829        let newer_normal_id = h_b.entry_id();
830        let (h_high, _) = r.enqueue(Toast::info(lit!("urgent")).priority(ToastPriority::High));
831        let live_ids = r.live_entry_ids();
832        assert!(
833            !live_ids.contains(&oldest_normal_id),
834            "oldest Normal must be evicted to make room for High"
835        );
836        assert!(live_ids.contains(&newer_normal_id));
837        assert!(live_ids.contains(&h_high.entry_id()));
838        assert_eq!(r.live_count(), 2);
839    }
840
841    #[test]
842    fn tick_timers_decrements_and_dismisses_on_expiry() {
843        let r = fresh_registry();
844        let (h, _) =
845            r.enqueue(Toast::info(lit!("fast")).auto_dismiss_after(Duration::from_millis(500)));
846        let id = h.entry_id();
847
848        // Tick 200ms — entry still alive.
849        let any_expired = r.tick_timers(Duration::from_millis(200), false);
850        assert!(!any_expired);
851        assert!(r.live_entry_ids().contains(&id));
852
853        // Tick another 350ms (total > 500) — entry expires.
854        let any_expired = r.tick_timers(Duration::from_millis(350), false);
855        assert!(any_expired);
856        assert!(!r.live_entry_ids().contains(&id));
857    }
858
859    #[test]
860    fn paused_tick_does_not_decrement() {
861        let r = fresh_registry();
862        let (h, _) =
863            r.enqueue(Toast::info(lit!("slow")).auto_dismiss_after(Duration::from_millis(300)));
864        // 10 ticks of 100ms (total 1s, well past 300ms) with paused=true.
865        for _ in 0..10 {
866            let any_expired = r.tick_timers(Duration::from_millis(100), true);
867            assert!(!any_expired);
868        }
869        assert!(r.live_entry_ids().contains(&h.entry_id()));
870    }
871
872    #[test]
873    fn persistent_toast_never_expires() {
874        let r = fresh_registry();
875        let (h, _) = r.enqueue(Toast::error(lit!("sticky")).persistent());
876        for _ in 0..50 {
877            let any_expired = r.tick_timers(Duration::from_secs(1), false);
878            assert!(!any_expired);
879        }
880        assert!(r.live_entry_ids().contains(&h.entry_id()));
881    }
882
883    #[test]
884    fn has_running_timers_gates_the_idle_frame_loop() {
885        // Regression: an empty toast host used to keep a permanent
886        // `frame_tick` subscription, waking the event loop at ~60 fps
887        // forever (a steady idle-CPU drain on any app that called
888        // `install_toast_default()`). The host now arms its per-frame
889        // timer only while `has_running_timers()` is true.
890        let r = fresh_registry();
891
892        // Empty queue: nothing to decrement → no subscription.
893        assert!(!r.has_running_timers());
894
895        // A sticky / persistent toast has no finite timer → still no tick.
896        let (sticky, _) = r.enqueue(Toast::error(lit!("sticky")).persistent());
897        assert!(!r.has_running_timers());
898
899        // A timed toast arms the timer → host subscribes.
900        let (timed, _) =
901            r.enqueue(Toast::info(lit!("timed")).auto_dismiss_after(Duration::from_millis(500)));
902        assert!(r.has_running_timers());
903
904        // Expire it: the only running timer is gone, so the host drops
905        // the subscription again even though the sticky toast remains.
906        let expired = r.tick_timers(Duration::from_millis(600), false);
907        assert!(expired);
908        assert!(!r.live_entry_ids().contains(&timed.entry_id()));
909        assert!(r.live_entry_ids().contains(&sticky.entry_id()));
910        assert!(!r.has_running_timers());
911    }
912
913    #[test]
914    fn loading_constructor_is_persistent_with_spinner_leading() {
915        let r = fresh_registry();
916        let (h, _) = r.enqueue(Toast::loading(lit!("Uploading")));
917        r.with_entry(h.entry_id(), |e| {
918            assert!(e.time_left.is_none(), "loading toasts are persistent");
919            assert!(
920                e.leading.is_some(),
921                "loading toasts carry a Spinner leading"
922            );
923            assert_eq!(e.severity, ToastSeverity::Info);
924        })
925        .unwrap();
926    }
927
928    #[test]
929    fn version_signal_bumps_on_enqueue_and_dismiss() {
930        let r = fresh_registry();
931        let initial = r.version_signal().get();
932        let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
933        let after_show = r.version_signal().get();
934        assert_ne!(initial, after_show, "version bumps on enqueue");
935
936        r.tick_timers(Duration::ZERO, false); // no-op, no expiry
937        // Expire one with a fast timer.
938        let (h2, _) =
939            r.enqueue(Toast::info(lit!("b")).auto_dismiss_after(Duration::from_millis(1)));
940        let _ = h2;
941        let pre_dismiss = r.version_signal().get();
942        r.tick_timers(Duration::from_millis(10), false);
943        let post_dismiss = r.version_signal().get();
944        assert_ne!(pre_dismiss, post_dismiss, "version bumps on timer dismiss");
945    }
946
947    #[test]
948    fn registry_with_archive_mirrors_pushes() {
949        use crate::notification::NotificationArchiveModel;
950        let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
951        let registry =
952            ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
953        let (_h1, _) = registry.enqueue(Toast::error(lit!("Build failed")));
954        let (_h2, _) = registry.enqueue(Toast::success(lit!("Deploy ok")));
955        // Both toasts mirrored.
956        assert_eq!(archive.entries().len(), 2);
957        // Newest first (the archive inserts at index 0).
958        assert_eq!(
959            archive.entries().with_item(0, |e| e.title.clone()),
960            Some("Deploy ok".into())
961        );
962        assert_eq!(archive.unread_count().get(), 2);
963    }
964
965    #[test]
966    fn registry_archive_false_skips_mirroring() {
967        use crate::notification::NotificationArchiveModel;
968        let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
969        let registry =
970            ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
971        // Default toast is archived.
972        let (_archived, _) = registry.enqueue(Toast::info(lit!("logged")));
973        // Opt-out toast is NOT archived.
974        let (_silent, _) = registry.enqueue(Toast::info(lit!("Copied!")).archive(false));
975        assert_eq!(archive.entries().len(), 1);
976        assert_eq!(
977            archive.entries().with_item(0, |e| e.title.clone()),
978            Some("logged".into())
979        );
980    }
981
982    #[test]
983    fn registry_with_id_updates_live_entry_in_place_keeping_entry_id() {
984        let r = fresh_registry();
985        let (first, _) = r.enqueue(Toast::loading(lit!("Uploading 1 of 7…")).id("upload"));
986        assert_eq!(r.live_count(), 1);
987        let first_entry_id = first.entry_id();
988
989        let (second, _) = r.enqueue(Toast::loading(lit!("Uploading 4 of 7…")).id("upload"));
990        // Same entry, NOT a new one.
991        assert_eq!(r.live_count(), 1, "live entry count stays at 1");
992        assert_eq!(
993            second.entry_id(),
994            first_entry_id,
995            "update returns the same entry_id — the original handle stays valid"
996        );
997
998        // The first handle is still alive (still points at the same
999        // entry that's still live).
1000        assert!(first.is_alive());
1001        assert!(second.is_alive());
1002    }
1003
1004    #[test]
1005    fn registry_in_place_update_reflects_new_title_body() {
1006        let r = fresh_registry();
1007        let (h, _) = r.enqueue(Toast::info(lit!("Saving")).id("save"));
1008        let _ = r.enqueue(
1009            Toast::success(lit!("Saved!"))
1010                .id("save")
1011                .body(lit!("Written 1.2 MB to disk.")),
1012        );
1013        r.with_entry(h.entry_id(), |e| {
1014            assert_eq!(e.title.resolve_now(), "Saved!", "title updated in place");
1015            assert_eq!(
1016                e.body.as_ref().map(|b| b.resolve_now()).as_deref(),
1017                Some("Written 1.2 MB to disk."),
1018                "body updated in place"
1019            );
1020            assert_eq!(e.severity, ToastSeverity::Success, "severity updated");
1021        })
1022        .unwrap();
1023    }
1024
1025    #[test]
1026    fn registry_in_place_update_resets_auto_dismiss_timer() {
1027        let r = fresh_registry();
1028        let (h, _) = r.enqueue(
1029            Toast::info(lit!("slow"))
1030                .id("ticker")
1031                .auto_dismiss_after(Duration::from_millis(500)),
1032        );
1033        // Tick almost to expiry on the first entry.
1034        r.tick_timers(Duration::from_millis(450), false);
1035        // Update: resets time_left to a fresh 500 ms.
1036        let _ = r.enqueue(
1037            Toast::info(lit!("slow #2"))
1038                .id("ticker")
1039                .auto_dismiss_after(Duration::from_millis(500)),
1040        );
1041        // A 100 ms tick should NOT dismiss it (timer was reset).
1042        let any_expired = r.tick_timers(Duration::from_millis(100), false);
1043        assert!(
1044            !any_expired,
1045            "timer reset on update — entry must survive a tick that would have expired the original"
1046        );
1047        assert!(h.is_alive());
1048    }
1049
1050    #[test]
1051    fn registry_in_place_update_preserves_leading_when_not_provided() {
1052        // The first call carries a Spinner via Toast::loading().
1053        // The second call has no `.leading(...)` — the spinner must
1054        // survive (so the demo's "Uploading 1 of 7" → "Uploading
1055        // 4 of 7" pattern keeps showing a spinner).
1056        let r = fresh_registry();
1057        let (h, _) = r.enqueue(Toast::loading(lit!("step 1")).id("upload"));
1058        // Probe: first build will take_leading; we test the registry's
1059        // intent (no take here, just verify it's still Some before the
1060        // update so we have a baseline).
1061        let has_spinner_initially = r.with_entry(h.entry_id(), |e| e.leading.is_some()).unwrap();
1062        assert!(has_spinner_initially, "loading toast carries a Spinner");
1063
1064        // Update with no leading set — preserves existing.
1065        let _ = r.enqueue(Toast::info(lit!("step 2")).id("upload"));
1066        let still_has_spinner = r.with_entry(h.entry_id(), |e| e.leading.is_some()).unwrap();
1067        assert!(
1068            still_has_spinner,
1069            "in-place update with no .leading(...) preserves the existing leading widget"
1070        );
1071    }
1072
1073    #[test]
1074    fn registry_in_place_update_preserves_on_dismiss_when_not_provided() {
1075        // Mirrors the leading-widget preservation test:
1076        // First toast attaches an on_dismiss callback; the update
1077        // has none, so the original callback must survive on the
1078        // live entry. (We can't easily simulate the callback firing
1079        // without a real EventContext, but the entry inspection
1080        // proves the preservation behaviour up to the fire point.)
1081        let r = fresh_registry();
1082        let (h, _) = r.enqueue(
1083            Toast::info(lit!("step 1"))
1084                .id("preserve-on-dismiss")
1085                .on_dismiss(|_cause, _ctx| {}),
1086        );
1087        // Sanity: the callback is attached.
1088        assert!(
1089            r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1090                .unwrap(),
1091            "original entry has on_dismiss attached"
1092        );
1093
1094        // Update with no on_dismiss — original must survive.
1095        let _ = r.enqueue(Toast::success(lit!("step 2")).id("preserve-on-dismiss"));
1096        assert!(
1097            r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1098                .unwrap(),
1099            "in-place update with no .on_dismiss(...) preserves the existing callback"
1100        );
1101
1102        // Update WITH a new on_dismiss replaces (we just verify the
1103        // field stays Some — the OLD callback gets dropped silently,
1104        // per the documented contract).
1105        let _ = r.enqueue(
1106            Toast::info(lit!("step 3"))
1107                .id("preserve-on-dismiss")
1108                .on_dismiss(|_cause, _ctx| {}),
1109        );
1110        assert!(
1111            r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1112                .unwrap(),
1113            "in-place update WITH new on_dismiss installs the replacement"
1114        );
1115    }
1116
1117    #[test]
1118    fn registry_in_place_update_without_id_appends_normally() {
1119        let r = fresh_registry();
1120        let _ = r.enqueue(Toast::info(lit!("a")));
1121        let _ = r.enqueue(Toast::info(lit!("b")));
1122        // No id on either — both appear as distinct entries.
1123        assert_eq!(r.live_count(), 2);
1124    }
1125
1126    #[test]
1127    fn registry_in_place_update_distinct_ids_do_not_collide() {
1128        let r = fresh_registry();
1129        let _ = r.enqueue(Toast::info(lit!("upload")).id("upload"));
1130        let _ = r.enqueue(Toast::info(lit!("download")).id("download"));
1131        // Different ids → two live entries.
1132        assert_eq!(r.live_count(), 2);
1133        // Updates target each independently.
1134        let _ = r.enqueue(Toast::success(lit!("Uploaded!")).id("upload"));
1135        assert_eq!(
1136            r.live_count(),
1137            2,
1138            "still two entries after upload-only update"
1139        );
1140    }
1141
1142    #[test]
1143    fn registry_with_id_merges_into_archive_in_place() {
1144        use crate::notification::NotificationArchiveModel;
1145        let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1146        let registry =
1147            ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1148        let (_a, _) = registry.enqueue(Toast::info(lit!("Uploading 1 of 7")).id("upload"));
1149        assert_eq!(archive.entries().len(), 1);
1150        let (_b, _) = registry.enqueue(Toast::info(lit!("Uploading 4 of 7")).id("upload"));
1151        // No new entry — the existing one was updated.
1152        assert_eq!(archive.entries().len(), 1);
1153        let merged = archive.entries().with_item(0, |e| e.clone()).unwrap();
1154        assert_eq!(merged.title, "Uploading 4 of 7");
1155        assert_eq!(merged.updates.len(), 1);
1156    }
1157
1158    #[test]
1159    fn registry_without_archive_does_not_panic() {
1160        // No archive configured — pushes still succeed; archive lookup
1161        // is just None.
1162        let registry = ToastRegistry::new(host::ToastInstallOptions::default());
1163        let (_h, _) = registry.enqueue(Toast::info(lit!("no archive here")));
1164        assert!(registry.archive().is_none());
1165    }
1166
1167    #[test]
1168    fn registry_archive_intent_name_survives_on_action() {
1169        use crate::notification::{ArchivedActionStyle, NotificationArchiveModel};
1170        let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1171        let registry =
1172            ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1173        let (_h, _) = registry
1174            .enqueue(Toast::error(lit!("Build failed")).action(
1175                ToastAction::primary(lit!("Retry"), |_| {}).shortcut_id("app.build.retry"),
1176            ));
1177        let entry = archive.entries().with_item(0, |e| e.clone()).unwrap();
1178        assert_eq!(entry.actions.len(), 1);
1179        assert_eq!(entry.actions[0].label, "Retry");
1180        assert_eq!(
1181            entry.actions[0].intent_name.as_deref(),
1182            Some("app.build.retry")
1183        );
1184        assert_eq!(entry.actions[0].style, ArchivedActionStyle::PrimaryButton);
1185        assert!(entry.actions[0].closes_on_invoke);
1186    }
1187
1188    #[test]
1189    fn archive_flag_is_captured_on_entry() {
1190        let r = fresh_registry();
1191        let (h_noarchive, _) = r.enqueue(Toast::info(lit!("Copied!")).archive(false));
1192        let archived = r.with_entry(h_noarchive.entry_id(), |e| e.archive).unwrap();
1193        assert!(!archived);
1194
1195        let (h_archived, _) = r.enqueue(Toast::error(lit!("Build failed")));
1196        let archived = r.with_entry(h_archived.entry_id(), |e| e.archive).unwrap();
1197        assert!(archived, "archive defaults to true");
1198    }
1199
1200    #[test]
1201    fn registry_mirrors_the_resolved_route_onto_the_archived_entry() {
1202        // Both the render-side host filter (`ToastHost::build`) and
1203        // the bell/log scoping filter (`notification::route_visible`)
1204        // trust `NotificationEntry::route` to match the LIVE entry's
1205        // resolved `ToastRoute`. Every existing scoped-bell test
1206        // (`notification::center_button`'s `scoped_bell_*` tests)
1207        // proves the FILTER is correct by hand-building a
1208        // `NotificationEntry` with an explicit route — none of them go
1209        // through the real `enqueue` → `entry_to_archive` mirror, so
1210        // none of them would notice if that mirror stopped copying the
1211        // route (e.g. a refactor that hardcoded `Broadcast` at the
1212        // mirror site, or dropped the field). This is that missing
1213        // link: enqueue through the real pipeline and check the
1214        // archived copy.
1215        use crate::notification::NotificationArchiveModel;
1216        let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1217        let registry =
1218            ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1219
1220        let audience = ToastAudience::new(99);
1221        let (h, _) = registry.enqueue(Toast::info(lit!("scoped")).target(audience));
1222        let live_route = registry.with_entry(h.entry_id(), |e| e.route).unwrap();
1223        assert_eq!(live_route, ToastRoute::Audience(audience));
1224
1225        let archived_route = archive.entries().with_item(0, |e| e.route).unwrap();
1226        assert_eq!(
1227            archived_route,
1228            ToastRoute::Audience(audience),
1229            "the archived NotificationEntry must carry the SAME route as the live \
1230             entry it was mirrored from"
1231        );
1232    }
1233
1234    // -----------------------------------------------------------------
1235    // AT role/live mapping (via ToastSurface)
1236    // -----------------------------------------------------------------
1237
1238    /// Build an `AccessNodeBuilder` directly from a `ToastSurface` so
1239    /// we can probe `role` AND `live` (the public `accessibility_node`
1240    /// helper only surfaces `role` + `name` + `actions`).
1241    fn surface_node(
1242        severity: ToastSeverity,
1243        priority: ToastPriority,
1244    ) -> teksilo_core::accessibility::AccessNodeBuilder {
1245        use crate::toast::surface::{ToastSurface, ToastSurfaceData};
1246        use teksilo_core::accessibility::AccessNodeBuilder;
1247        use teksilo_core::widget::Widget;
1248        let data = ToastSurfaceData {
1249            entry_id: 1,
1250            severity,
1251            priority,
1252            title: teksilo_i18n::lit!("x"),
1253            body: None,
1254            announcement: None,
1255            actions: Rc::new(Vec::new()),
1256            show_close_button: false,
1257            on_click: None,
1258            style_override: None,
1259            body_state: teksilo_core::signal::Signal::new(0),
1260        };
1261        let surface = ToastSurface::new(data, None, fresh_registry(), false);
1262        let mut builder = AccessNodeBuilder::new();
1263        surface.accessibility(&mut builder);
1264        builder
1265    }
1266
1267    fn surface_role_for(
1268        severity: ToastSeverity,
1269        priority: ToastPriority,
1270    ) -> teksilo_core::accesskit::Role {
1271        surface_node(severity, priority).role()
1272    }
1273
1274    fn surface_live_for(
1275        severity: ToastSeverity,
1276        priority: ToastPriority,
1277    ) -> teksilo_core::accesskit::Live {
1278        let mut node = surface_node(severity, priority);
1279        node.inner_mut()
1280            .live()
1281            .unwrap_or(teksilo_core::accesskit::Live::Off)
1282    }
1283
1284    #[test]
1285    fn at_role_status_for_info_success_warning_normal() {
1286        use teksilo_core::accesskit::Role;
1287        assert_eq!(
1288            surface_role_for(ToastSeverity::Info, ToastPriority::Normal),
1289            Role::Status
1290        );
1291        assert_eq!(
1292            surface_role_for(ToastSeverity::Success, ToastPriority::Normal),
1293            Role::Status
1294        );
1295        assert_eq!(
1296            surface_role_for(ToastSeverity::Warning, ToastPriority::Normal),
1297            Role::Status
1298        );
1299    }
1300
1301    #[test]
1302    fn at_role_alert_for_error_and_warning_high() {
1303        use teksilo_core::accesskit::Role;
1304        assert_eq!(
1305            surface_role_for(ToastSeverity::Error, ToastPriority::Normal),
1306            Role::Alert
1307        );
1308        assert_eq!(
1309            surface_role_for(ToastSeverity::Error, ToastPriority::High),
1310            Role::Alert
1311        );
1312        assert_eq!(
1313            surface_role_for(ToastSeverity::Warning, ToastPriority::High),
1314            Role::Alert
1315        );
1316        assert_eq!(
1317            surface_role_for(ToastSeverity::Warning, ToastPriority::Urgent),
1318            Role::Alert
1319        );
1320    }
1321
1322    #[test]
1323    fn at_live_polite_for_status_assertive_for_alert() {
1324        use teksilo_core::accesskit::Live;
1325        assert_eq!(
1326            surface_live_for(ToastSeverity::Info, ToastPriority::Normal),
1327            Live::Polite
1328        );
1329        assert_eq!(
1330            surface_live_for(ToastSeverity::Error, ToastPriority::Normal),
1331            Live::Assertive
1332        );
1333        assert_eq!(
1334            surface_live_for(ToastSeverity::Warning, ToastPriority::High),
1335            Live::Assertive
1336        );
1337    }
1338
1339    #[test]
1340    fn urgent_priority_forces_assertive_regardless_of_severity() {
1341        use teksilo_core::accesskit::Live;
1342        // Info + Urgent = Assertive even though Info would normally be Polite.
1343        assert_eq!(
1344            surface_live_for(ToastSeverity::Info, ToastPriority::Urgent),
1345            Live::Assertive
1346        );
1347        assert_eq!(
1348            surface_live_for(ToastSeverity::Success, ToastPriority::Urgent),
1349            Live::Assertive
1350        );
1351    }
1352
1353    // -----------------------------------------------------------------
1354    // End-to-end: WidgetTree + ToastHost + ToastRegistry
1355    // -----------------------------------------------------------------
1356
1357    use crate::primitives::TextWidget as TestLeaf; // any layout-only widget works as user_root
1358
1359    fn setup_host_tree(
1360        opts: host::ToastInstallOptions,
1361    ) -> (teksilo_core::widget_tree::WidgetTree, ToastRegistry) {
1362        use std::any::{Any, TypeId};
1363        use std::collections::HashMap;
1364
1365        let registry = ToastRegistry::new(opts.clone());
1366        let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1367        app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1368
1369        let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1370            .with_theme(teksilo_core::presets::intui::light());
1371        tree.set_app_context(Rc::new(
1372            teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1373        ));
1374        let user_root = tree.add(TestLeaf::new(lit!("user content")));
1375        let host = ToastHost::wrapping(user_root, registry.clone(), opts);
1376        tree.add(host);
1377        tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1378        (tree, registry)
1379    }
1380
1381    #[test]
1382    fn host_renders_a_toast_surface_for_each_live_entry() {
1383        use std::any::{Any, TypeId};
1384        use std::collections::HashMap;
1385
1386        // Pre-populate the registry with a toast BEFORE the host is
1387        // added, so the host's first build sees the entry. (The
1388        // version-binding rebuild path requires a fresh dirty-flush
1389        // pass which is exercised in `dismiss_clears_surface` below.)
1390        let opts = host::ToastInstallOptions::default();
1391        let registry = ToastRegistry::new(opts.clone());
1392        let _h = registry.enqueue(Toast::success(lit!("Saved")));
1393        assert_eq!(registry.live_count(), 1);
1394
1395        let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1396        app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1397
1398        let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1399            .with_theme(teksilo_core::presets::intui::light());
1400        tree.set_app_context(Rc::new(
1401            teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1402        ));
1403        let user_root = tree.add(TestLeaf::new(lit!("user content")));
1404        tree.add(ToastHost::wrapping(user_root, registry.clone(), opts));
1405        tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1406
1407        assert!(
1408            tree.find_by_role(teksilo_core::accesskit::Role::Status)
1409                .is_some(),
1410            "Success toast renders a Role::Status surface in the host"
1411        );
1412    }
1413
1414    #[test]
1415    fn host_promotes_error_toast_to_role_alert() {
1416        use std::any::{Any, TypeId};
1417        use std::collections::HashMap;
1418        let opts = host::ToastInstallOptions::default();
1419        let registry = ToastRegistry::new(opts.clone());
1420        let _h = registry.enqueue(Toast::error(lit!("Build failed")).persistent());
1421
1422        let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1423        app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1424        let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1425            .with_theme(teksilo_core::presets::intui::light());
1426        tree.set_app_context(Rc::new(
1427            teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1428        ));
1429        let user_root = tree.add(TestLeaf::new(lit!("root")));
1430        tree.add(ToastHost::wrapping(user_root, registry.clone(), opts));
1431        tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1432        assert!(
1433            tree.find_by_role(teksilo_core::accesskit::Role::Alert)
1434                .is_some(),
1435            "Error toast emits Role::Alert via the surface widget"
1436        );
1437    }
1438
1439    #[test]
1440    fn hover_count_flips_paused_state_observed_by_host_tick() {
1441        // Direct test of the contract between the surface (which writes
1442        // to hover_count) and the host tick (which reads it). We don't
1443        // need a full WidgetTree — the registry is the integration
1444        // surface.
1445        let r = fresh_registry();
1446        let (h, _) =
1447            r.enqueue(Toast::info(lit!("hover me")).auto_dismiss_after(Duration::from_millis(200)));
1448        // Simulate pointer-enter on the surface: hover_count = 1.
1449        r.hover_count_signal().set(1);
1450        // 10 ticks of 100 ms each (total 1 s, well past 200 ms) with
1451        // paused=hover_count>0 → entry survives.
1452        for _ in 0..10 {
1453            let hover = r.hover_count_signal().get() > 0;
1454            r.tick_timers(Duration::from_millis(100), hover);
1455        }
1456        assert!(
1457            r.live_entry_ids().contains(&h.entry_id()),
1458            "hover-paused entry must survive past its auto-dismiss window"
1459        );
1460        // Pointer-leave: hover_count = 0, timer resumes.
1461        r.hover_count_signal().set(0);
1462        let hover = r.hover_count_signal().get() > 0;
1463        r.tick_timers(Duration::from_millis(250), hover);
1464        assert!(
1465            !r.live_entry_ids().contains(&h.entry_id()),
1466            "after un-hover, entry expires"
1467        );
1468    }
1469
1470    // -----------------------------------------------------------------
1471    // Routing — origin window default / .target() / .broadcast()
1472    // -----------------------------------------------------------------
1473
1474    /// A toast presented through a real `EventContext` (an actual
1475    /// input handler, not a bare `enqueue` call) with no explicit
1476    /// `.target()` / `.broadcast()` must be tagged with the
1477    /// *presenting window's* id — the whole point of the "default =
1478    /// origin window" design is that existing single-window apps get
1479    /// correct routing for free.
1480    #[test]
1481    fn show_toast_default_targets_the_originating_window() {
1482        use crate::button::Button;
1483        use std::any::{Any, TypeId};
1484        use std::collections::HashMap;
1485
1486        use teksilo_core::window::state::WindowStateInit;
1487        use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
1488
1489        let registry = fresh_registry();
1490        let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1491        app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1492
1493        let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1494            .with_theme(teksilo_core::presets::intui::light());
1495        tree.set_app_context(Rc::new(
1496            teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1497        ));
1498        tree.set_window_state(WindowState::new(WindowStateInit {
1499            id: TeksiloWindowId::new(1),
1500            string_id: Some("test".to_string()),
1501            placement: WindowPlacement::Floating,
1502            title: "Test".to_string(),
1503            size: (800, 600),
1504            position: (0, 0),
1505            focused: false,
1506            resizable: true,
1507            always_on_top: false,
1508        }));
1509
1510        let btn = tree.add(Button::new(lit!("Save")).on_activate_fn(|ctx| {
1511            let _ = Toast::info(lit!("Saved")).present(ctx);
1512        }));
1513        tree.layout(teksilo_canvas::SizeProposal::exact(400.0, 300.0));
1514
1515        tree.click(btn);
1516
1517        assert_eq!(
1518            registry.live_count(),
1519            1,
1520            "the click must have enqueued a toast"
1521        );
1522        let eid = registry.live_entry_ids()[0];
1523        registry
1524            .with_entry(eid, |e| {
1525                assert_eq!(
1526                    e.route,
1527                    ToastRoute::Window(TeksiloWindowId::new(1)),
1528                    "no explicit target + a real window at present time → origin-window route"
1529                );
1530            })
1531            .unwrap();
1532    }
1533
1534    #[test]
1535    fn target_routes_to_the_matching_audience_only() {
1536        let r = fresh_registry();
1537        let audience = ToastAudience::new(42);
1538        let (h, _) = r.enqueue(Toast::info(lit!("scoped")).target(audience));
1539        r.with_entry(h.entry_id(), |e| {
1540            assert_eq!(e.route, ToastRoute::Audience(audience));
1541        })
1542        .unwrap();
1543    }
1544
1545    #[test]
1546    fn broadcast_routes_regardless_of_window_or_audience() {
1547        let r = fresh_registry();
1548        let (h, _) = r.enqueue(Toast::warning(lit!("everyone")).broadcast());
1549        r.with_entry(h.entry_id(), |e| {
1550            assert_eq!(e.route, ToastRoute::Broadcast);
1551        })
1552        .unwrap();
1553    }
1554
1555    #[test]
1556    fn no_window_no_target_falls_back_to_broadcast() {
1557        // Mirrors `show_settings_write_failed`'s call path: `enqueue`
1558        // called directly, no `EventContext`, no explicit `.target()`.
1559        // The only sensible default for a routeless, contextless toast
1560        // is app-wide, not "nowhere".
1561        let r = fresh_registry();
1562        let (h, _) = r.enqueue(Toast::error(lit!("no context here")));
1563        r.with_entry(h.entry_id(), |e| {
1564            assert_eq!(e.route, ToastRoute::Broadcast);
1565        })
1566        .unwrap();
1567    }
1568
1569    #[test]
1570    fn per_audience_admission_one_burst_does_not_starve_another_audience() {
1571        // Decision 1: `max_visible` is enforced PER routing bucket.
1572        // A burst of audience A's toasts past the pool size must not
1573        // touch audience B's slots at all.
1574        let r = small_registry(2);
1575        let audience_a = ToastAudience::new(1);
1576        let audience_b = ToastAudience::new(2);
1577
1578        let (a1, _) = r.enqueue(Toast::info(lit!("a1")).target(audience_a));
1579        let (a2, _) = r.enqueue(Toast::info(lit!("a2")).target(audience_a));
1580        // A's bucket is now full (max_visible = 2). A third A toast
1581        // must be dropped exactly like the single-bucket overflow test.
1582        let (a3, _) = r.enqueue(Toast::info(lit!("a3")).target(audience_a));
1583        assert!(
1584            !a3.is_alive(),
1585            "audience A's third toast overflows its own bucket"
1586        );
1587        assert!(a1.is_alive() && a2.is_alive());
1588
1589        // Audience B has its own, untouched pool of 2 slots.
1590        let (b1, _) = r.enqueue(Toast::info(lit!("b1")).target(audience_b));
1591        let (b2, _) = r.enqueue(Toast::info(lit!("b2")).target(audience_b));
1592        assert!(
1593            b1.is_alive() && b2.is_alive(),
1594            "audience B's own admission must be unaffected by A's burst"
1595        );
1596        assert_eq!(
1597            r.live_count(),
1598            4,
1599            "2 live A entries + 2 live B entries — B was never starved"
1600        );
1601
1602        // A third B toast still overflows B's own bucket (proves the
1603        // bucketing is real, not just "everything fits because global
1604        // max_visible was raised somewhere").
1605        let (b3, _) = r.enqueue(Toast::info(lit!("b3")).target(audience_b));
1606        assert!(!b3.is_alive());
1607        assert_eq!(r.live_count(), 4);
1608    }
1609
1610    #[test]
1611    fn per_audience_high_priority_evicts_oldest_normal_within_the_same_bucket_only() {
1612        let r = small_registry(2);
1613        let audience_a = ToastAudience::new(1);
1614        let audience_b = ToastAudience::new(2);
1615
1616        let (a_old, _) = r.enqueue(Toast::info(lit!("a-old")).target(audience_a));
1617        let (a_new, _) = r.enqueue(Toast::info(lit!("a-new")).target(audience_a));
1618        let (b1, _) = r.enqueue(Toast::info(lit!("b1")).target(audience_b));
1619        let (b2, _) = r.enqueue(Toast::info(lit!("b2")).target(audience_b));
1620
1621        // A High-priority arrival targeting audience A must evict only
1622        // the oldest Normal entry WITHIN audience A's bucket — B's
1623        // entries must survive untouched.
1624        let (a_high, _) = r.enqueue(
1625            Toast::info(lit!("a-urgent"))
1626                .target(audience_a)
1627                .priority(ToastPriority::High),
1628        );
1629        let live_ids = r.live_entry_ids();
1630        assert!(
1631            !live_ids.contains(&a_old.entry_id()),
1632            "oldest Normal within audience A's bucket is evicted"
1633        );
1634        assert!(live_ids.contains(&a_new.entry_id()));
1635        assert!(live_ids.contains(&a_high.entry_id()));
1636        assert!(
1637            live_ids.contains(&b1.entry_id()) && live_ids.contains(&b2.entry_id()),
1638            "audience B's entries must be untouched by A's High-priority eviction"
1639        );
1640        assert_eq!(r.live_count(), 4);
1641    }
1642
1643    #[test]
1644    fn host_renders_no_surfaces_when_registry_empty() {
1645        // Inverse of the above: with no live entries the host has no
1646        // toast surfaces in the AT tree.
1647        let (tree, registry) = setup_host_tree(host::ToastInstallOptions::default());
1648        assert_eq!(registry.live_count(), 0);
1649        assert!(
1650            tree.find_by_role(teksilo_core::accesskit::Role::Status)
1651                .is_none()
1652        );
1653        assert!(
1654            tree.find_by_role(teksilo_core::accesskit::Role::Alert)
1655                .is_none()
1656        );
1657    }
1658}