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