Skip to main content

teksilo_widgets/toast/
registry.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ToastRegistry` — the app-singleton service handle.
5//!
6//! Registered into the app-state registry by `install_toast(opts)`
7//! (lives in the `teksilo` umbrella). Holds the queue + per-entry
8//! state shared between:
9//!
10//! - [`crate::toast::ext::EventContextToastExt`] — looks up the
11//!   registry to fulfil `ctx.show_toast(toast)`.
12//! - [`crate::toast::host::ToastHost`] — reads `live_entries` to
13//!   render each rebuild, owns the per-frame timer, and registers
14//!   itself here in `build()` so `show_toast` can find it.
15//! - [`crate::toast::surface::ToastSurface`] — calls back into the
16//!   registry to dismiss its entry on close-click / action-invoked.
17//!
18//! Routing: every live entry (and its mirrored archive row) carries a
19//! resolved [`ToastRoute`] — the presenting window by default, or an
20//! explicit audience/broadcast target. `max_visible` admission and
21//! High/Urgent eviction are bucketed per route (see `ToastRegistry::enqueue`) so a
22//! burst of one window's/audience's toasts can never starve another's
23//! slot pool. [`ToastHost`](super::host::ToastHost) does the
24//! complementary filtering on the render side.
25
26use std::cell::RefCell;
27use std::collections::{HashMap, VecDeque};
28use std::rc::Rc;
29use std::time::{Duration, Instant};
30
31use teksilo_core::signal::Signal;
32use teksilo_core::styles::{SharedToastStyle, ToastPriority};
33use teksilo_core::widget::{EventContext, Widget};
34use teksilo_core::window::TeksiloWindowId;
35
36use crate::notification::{
37    ArchivedAction, ArchivedActionStyle, NotificationArchiveModel, NotificationEntry,
38};
39use crate::toast::{
40    Toast, ToastAction, ToastActionStyle, ToastAudience, ToastDismissCallback, ToastDismissCause,
41    ToastHandle, ToastHandleInner, ToastRoute, ToastSeverity,
42};
43use teksilo_i18n::{LocalizedString, tr_widget};
44
45/// Cheap to clone (`Rc<RefCell<…>>`). All public methods take `&self`
46/// and use interior mutability.
47#[derive(Clone)]
48pub struct ToastRegistry {
49    inner: Rc<RefCell<ToastRegistryInner>>,
50    /// Shared hover-pause refcount. Each `ToastSurface` increments
51    /// on pointer-enter, decrements on pointer-leave. Read by the
52    /// host's frame-tick effect: `count > 0` pauses every entry's
53    /// timer. Exposed via [`Self::hover_count_signal`] so the surface
54    /// widget can wire its handler.
55    hover_count: Signal<usize>,
56    /// Version signal bumped on every queue mutation. The host binds
57    /// to this at `BindingLevel::Rebuild` so any
58    /// show/dismiss/timer-tick triggers a fresh host rebuild.
59    version: Signal<u64>,
60    /// Optional persistent / in-memory notification archive. Each
61    /// enqueue mirrors the toast (when its `archive` flag is true)
62    /// into the model — this is what
63    /// [`NotificationLog`](crate::notification) renders and what
64    /// drives the bell-button badge. `None` when the install helper
65    /// was configured with `archive: None`.
66    archive: Option<Rc<NotificationArchiveModel>>,
67    /// Per-window audience assignment. `ToastHost::build` calls
68    /// [`Self::window_audience_signal`] to get-or-create a stable
69    /// signal for its own window id and binds to it at
70    /// `BindingLevel::Rebuild`; app code retargets a window (e.g. when
71    /// its active document changes) by calling
72    /// [`Self::set_window_audience`] — reached the same way the
73    /// registry itself is reached (`ctx.app_state::<ToastRegistry>()`),
74    /// so no new `app_state` type is needed. Lives on the registry
75    /// (not as a second `app_state` entry) because `app_state` holds
76    /// exactly one instance per type for the whole app — there is no
77    /// per-window slot to put this in anywhere else.
78    window_audiences: Rc<RefCell<HashMap<TeksiloWindowId, Signal<Option<ToastAudience>>>>>,
79}
80
81pub(crate) struct ToastRegistryInner {
82    pub(crate) next_entry_id: u64,
83    pub(crate) live_entries: VecDeque<LiveEntry>,
84    /// Pending dismissal callbacks (cause + user callback) for entries
85    /// whose timer fired in a frame-tick context. The host's
86    /// `.on_pointer_event` handler drains this on the next pointer
87    /// event so the user callback runs with a real `EventContext`.
88    pub(crate) pending_user_dismiss_callbacks: Vec<(ToastDismissCause, ToastDismissCallback)>,
89    /// Maximum simultaneous live entries PER ROUTE BUCKET (window /
90    /// audience / broadcast each count separately) — overflow toasts
91    /// are dropped with cause `SlotPoolFull` (Normal priority) or evict
92    /// the oldest Normal entry in the SAME bucket (High / Urgent
93    /// priority). See `enqueue`'s bucketing.
94    pub(crate) max_visible: usize,
95    pub(crate) pause_on_hover_group: bool,
96}
97
98/// Per-toast state owned by the registry, snapshotted into the
99/// `ToastSurface` each host rebuild.
100pub(crate) struct LiveEntry {
101    pub(crate) entry_id: u64,
102    pub(crate) severity: ToastSeverity,
103    pub(crate) priority: ToastPriority,
104    pub(crate) title: LocalizedString,
105    pub(crate) body: Option<LocalizedString>,
106    pub(crate) announcement: Option<LocalizedString>,
107    pub(crate) actions: Rc<Vec<ToastAction>>,
108    pub(crate) show_close_button: bool,
109    pub(crate) closable_on_escape: bool,
110    pub(crate) on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
111    pub(crate) on_dismiss: Option<ToastDismissCallback>,
112    pub(crate) style_override: Option<SharedToastStyle>,
113    /// `None` for persistent toasts. Decremented each frame by the
114    /// host's frame-tick effect when the hover-pause refcount is zero.
115    pub(crate) time_left: Option<Duration>,
116    /// The instant `time_left` is accounted up to. Each entry is charged from
117    /// its own mark, never from the host's last tick: a toast shown between
118    /// two ticks used to pay for the time before it existed, and left with
119    /// the older toast beside it.
120    pub(crate) timer_mark: Instant,
121    /// How many of this toast's surfaces have keyboard focus on them or
122    /// inside them: one per window at most, since a broadcast toast has a
123    /// surface in every window. Any holder pauses the timers like a resting
124    /// pointer does: a reader who has Tabbed to a toast's action must not
125    /// have it expire under them (WCAG 2.2.1).
126    pub(crate) focus_holders: u32,
127    /// Bumped on every update in place. The entry's surface binds it, so an
128    /// update rebuilds that one surface and nothing else; see
129    /// [`ToastSurface`](super::surface::ToastSurface).
130    pub(crate) revision: Signal<u64>,
131    /// Whether a pointer is **holding** this toast down. The touch twin of the
132    /// hover refcount: a finger produces no hover, so pressing and holding the
133    /// surface is how a touch user says "wait, I am reading this".
134    ///
135    /// A flag on the entry rather than a second refcount, and deliberately: a
136    /// count leaks if its decrement is missed — a contact revoked mid-hold
137    /// would pin every toast in the application open for the rest of its run —
138    /// whereas a flag on the entry dies with the entry. It is written from the
139    /// framework's own press signal, which the framework clears on release and
140    /// on cancel alike.
141    pub(crate) held: bool,
142    /// Boxed custom leading widget — `take()`-able exactly once when
143    /// the surface is built. After the first build, subsequent
144    /// rebuilds fall back to the default severity glyph.
145    pub(crate) leading: Option<Box<dyn Widget>>,
146    /// `Toast::id(...)` value. Consumed by `enqueue` for live
147    /// update-in-place merge (a subsequent enqueue with a matching
148    /// `id` mutates this entry rather than appending) and projected
149    /// into `NotificationEntry::dedup_id` for the archive-side merge.
150    pub(crate) id: Option<String>,
151    /// `Toast::archive(true|false)` value. `false` opts the entry
152    /// out of the persistent archive mirror (transient toasts like
153    /// quick "Copied!" feedback that shouldn't pollute the log).
154    pub(crate) archive: bool,
155    /// Resolved delivery target — see [`ToastRoute`]. Drives both
156    /// `ToastHost`'s render-side filter and the per-route slot-pool
157    /// admission/eviction bucketing in [`ToastRegistry::enqueue`].
158    pub(crate) route: ToastRoute,
159    /// Whether this entry's body is clamped, unfolded, or short enough not to care
160    /// (`crate::toast::body::BodyState`, as a scalar).
161    ///
162    /// It lives on the **entry**, not inside the body widget, because `ToastHost` builds
163    /// a fresh `ToastSurface` on every rebuild — so a signal owned by the widget would
164    /// reset each time any *other* toast arrived or expired, silently re-folding
165    /// something the reader had just opened. The entry outlives every rebuild, and
166    /// cloning a `Signal` shares its state, so threading it through
167    /// `ToastSurfaceData` keeps the disclosure sticky for as long as the toast exists.
168    pub(crate) body_state: Signal<u8>,
169}
170
171impl ToastRegistry {
172    /// Construct a registry with the given options and no archive.
173    /// Used by tests and by apps that don't want notification
174    /// persistence. The install helper in teksilo calls
175    /// [`with_archive`](Self::with_archive) instead.
176    pub fn new(options: super::host::ToastInstallOptions) -> Self {
177        Self::build(options, None)
178    }
179
180    /// Construct a registry that mirrors every archived-eligible
181    /// toast push into `archive`. Toasts presented with
182    /// `archive(false)` are NOT mirrored (used for transient
183    /// "Copied!" feedback that shouldn't pollute the log).
184    pub fn with_archive(
185        options: super::host::ToastInstallOptions,
186        archive: Rc<NotificationArchiveModel>,
187    ) -> Self {
188        Self::build(options, Some(archive))
189    }
190
191    fn build(
192        options: super::host::ToastInstallOptions,
193        archive: Option<Rc<NotificationArchiveModel>>,
194    ) -> Self {
195        Self {
196            inner: Rc::new(RefCell::new(ToastRegistryInner {
197                next_entry_id: 1,
198                live_entries: VecDeque::new(),
199                pending_user_dismiss_callbacks: Vec::new(),
200                max_visible: options.max_visible,
201                pause_on_hover_group: options.pause_on_hover_group,
202            })),
203            hover_count: Signal::new(0),
204            version: Signal::new(0),
205            archive,
206            window_audiences: Rc::new(RefCell::new(HashMap::new())),
207        }
208    }
209
210    /// Access the underlying notification archive (if configured).
211    /// `NotificationLog` and `NotificationCenterButton` read from
212    /// this directly.
213    pub fn archive(&self) -> Option<Rc<NotificationArchiveModel>> {
214        self.archive.clone()
215    }
216
217    /// Reactive signal bumped on every queue mutation. Every
218    /// `ToastHost` binds this at `BindingLevel::Rebuild`, in every
219    /// window, and app code may also poll it directly to assert "did
220    /// something change" without going through a widget tree at all.
221    ///
222    /// One signal is enough for N windows. It was not always: dirty
223    /// tracking used to be a `bool` living on the signal that each
224    /// `WidgetTree`'s reconcile pass read *and cleared*, so whichever
225    /// window reconciled first consumed the flag and every other
226    /// window's `ToastHost` silently — and permanently — skipped its
227    /// rebuild. Toast routing was the first feature to need
228    /// shared-state-fanned-out-to-every-window, so it was the first to
229    /// hit that, and it carried a `HashMap<TeksiloWindowId, Signal<u64>>`
230    /// of per-window duplicates plus a fan-out on every bump to work
231    /// around it. `Signal` now tracks a monotone generation and each
232    /// `BindingRegistry` remembers what it last acted on
233    /// (`teksilo_core::binding::BindingGroup::last_seen`), so consumers
234    /// no longer contend and the duplicates are gone.
235    pub fn version_signal(&self) -> &Signal<u64> {
236        &self.version
237    }
238
239    /// Shared hover-pause refcount. Surfaces increment / decrement
240    /// on hover-enter / leave; the host's frame-tick effect reads it.
241    pub fn hover_count_signal(&self) -> Signal<usize> {
242        self.hover_count.clone()
243    }
244
245    /// Get-or-create the audience signal for `window_id`. The first
246    /// call for a given window allocates a fresh `Signal::new(None)`;
247    /// every later call (from that window's `ToastHost`, or from app
248    /// code) returns the SAME signal, so binding to it once and
249    /// mutating it later both work through this one accessor.
250    pub fn window_audience_signal(
251        &self,
252        window_id: TeksiloWindowId,
253    ) -> Signal<Option<ToastAudience>> {
254        self.window_audiences
255            .borrow_mut()
256            .entry(window_id)
257            .or_insert_with(|| Signal::new(None))
258            .clone()
259    }
260
261    /// Assign (or clear, with `None`) the audience for `window_id`.
262    /// Retargets that window's toast host + bell immediately — both
263    /// are bound to this signal at `BindingLevel::Rebuild`. Reached
264    /// exactly like the registry itself: `ctx.app_state::<ToastRegistry>()`.
265    /// Typical call site: a window-activation / active-document-changed
266    /// handler that keeps a window's audience in sync with what it's
267    /// currently showing.
268    pub fn set_window_audience(&self, window_id: TeksiloWindowId, audience: Option<ToastAudience>) {
269        self.window_audience_signal(window_id).set(audience);
270    }
271
272    /// Drop `window_id`'s entry from `window_audiences`.
273    /// Call this from the app's window-teardown hook — the same place
274    /// that tears down the `ToastHost` mounted in that window.
275    ///
276    /// **`set_window_audience(window_id, None)` is NOT a substitute.**
277    /// That call only overwrites the signal's *value*; the map entry
278    /// (and the `Signal`'s backing `Rc<RefCell<..>>` allocation) stays
279    /// alive. Without a call to `forget_window`, every window ever
280    /// opened for the life of the process leaves one live `Signal` in
281    /// the map behind forever — an unbounded leak in exactly the
282    /// shape a long-running, multi-window app has (open/close windows
283    /// repeatedly across a session).
284    ///
285    /// Safe even if some other code still holds a clone of the
286    /// removed `Signal`: a `Signal` is `Rc<RefCell<..>>` under the
287    /// hood, so dropping the registry's map entry only drops *this*
288    /// reference to it — any clone a still-alive holder kept keeps
289    /// reading/writing exactly as before, unaffected by the map
290    /// removal (`Rc` content doesn't disappear just because one owner
291    /// let go of it). The only real hazard is calling this too early:
292    /// [`Self::window_audience_signal`] is get-or-create, so if the
293    /// torn-down window's own `ToastHost` (or any other live widget)
294    /// calls it again AFTER `forget_window`, it transparently
295    /// allocates a brand-new `Signal::new(_)` under the same key
296    /// rather than erroring — fine for a window that is genuinely gone
297    /// (nothing is bound to the discarded signal any more, so no
298    /// rebuild is missed), but it means this must be called from
299    /// teardown itself, not from a handler the window's own event loop
300    /// might still reach afterwards.
301    ///
302    /// Idempotent: forgetting a window id that was never registered
303    /// (or was already forgotten) is a safe no-op — `HashMap::remove`
304    /// on a missing key does nothing.
305    pub fn forget_window(&self, window_id: TeksiloWindowId) {
306        self.window_audiences.borrow_mut().remove(&window_id);
307    }
308
309    /// Bump the version every `ToastHost` binds at
310    /// `BindingLevel::Rebuild` — see [`Self::version_signal`]. One
311    /// write reaches every window: each window's own `BindingRegistry`
312    /// tracks the generation it last reconciled, so none of them can
313    /// consume the notification out from under the others.
314    pub(crate) fn bump_version(&self) {
315        let v = self.version.get();
316        self.version.set(v.wrapping_add(1));
317    }
318
319    /// Enqueue a toast. Called by `show_toast`. Returns a stable
320    /// [`ToastHandle`]. Slot-pool exhaustion is evaluated PER ROUTE
321    /// BUCKET (see [`ToastRoute`]): Normal-priority toasts are dropped
322    /// with cause [`ToastDismissCause::SlotPoolFull`] once their own
323    /// bucket is full; High / Urgent evict the oldest Normal entry in
324    /// that SAME bucket — a burst of one window's or one audience's
325    /// toasts never touches another's slots.
326    pub(crate) fn enqueue(
327        &self,
328        toast: Toast,
329    ) -> (
330        ToastHandle,
331        Option<(ToastDismissCause, ToastDismissCallback)>,
332    ) {
333        // Resolved once, up front: `toast.target` is `None` for a
334        // toast that reached `enqueue` directly with no `EventContext`
335        // and no explicit `.target()`/`.broadcast()` (the
336        // `show_settings_write_failed` path) — `Broadcast` is the only
337        // sensible default for an app-wide, contextless notification.
338        // Every other caller (`EventContextToastExt::show_toast`) has
339        // already resolved `None` to `Window(origin)` before this runs.
340        let resolved_route = toast.target.unwrap_or(ToastRoute::Broadcast);
341        let mut inner = self.inner.borrow_mut();
342
343        // Update-in-place: a toast carrying a `Toast::id(...)` value
344        // that matches an existing live entry mutates that entry's
345        // fields instead of appending a new one. Reuses the existing
346        // entry_id so the original `ToastHandle` (returned by the
347        // first call) keeps working; resets the auto-dismiss timer.
348        // Bypasses slot-pool admission (an update doesn't add a new
349        // slot) and the on_dismiss-for-overflow path.
350        if let Some(ref dedup_id) = toast.id
351            && let Some(existing) = inner
352                .live_entries
353                .iter_mut()
354                .find(|e| e.id.as_deref() == Some(dedup_id.as_str()))
355        {
356            let severity_changed = existing.severity != toast.severity;
357            existing.severity = toast.severity;
358            // A retargeting update (e.g. a progress toast whose
359            // audience becomes known partway through) takes effect —
360            // subsequent admission/eviction and host filtering use the
361            // new route immediately.
362            existing.route = resolved_route;
363            existing.priority = toast.priority;
364            existing.title = toast.title;
365            existing.body = toast.body;
366            existing.announcement = toast.announcement;
367            existing.actions = Rc::new(toast.actions);
368            existing.show_close_button = toast.show_close_button;
369            existing.closable_on_escape = toast.closable_on_escape;
370            existing.on_click = toast.on_click;
371            // Replace the on_dismiss callback only if the update
372            // provided one — apps that just want to update title /
373            // body don't have to re-supply on_dismiss every time.
374            // When the update DOES provide a new callback, the
375            // previous one is dropped silently (never fires). The
376            // contract is "the most recent caller's expectations win"
377            // — `on_dismiss` fires once per entry, with the
378            // most-recently-supplied callback.
379            if toast.on_dismiss.is_some() {
380                existing.on_dismiss = toast.on_dismiss;
381            }
382            existing.style_override = toast.style_override;
383            existing.time_left = toast.auto_dismiss_after;
384            existing.timer_mark = Instant::now();
385            // Leading widget: replace when the update sets one. Otherwise
386            // *keep* the original (typically a Spinner from `Toast::loading`)
387            // for a same-severity text-only update — EXCEPT when the severity
388            // changed (e.g. a loading toast is updated to `success`): then the
389            // stale custom leading (the spinner) must be dropped so the surface
390            // shows the new severity's glyph (the ✓/✕/… icon), not a spinner
391            // that keeps spinning under a "success" title.
392            if toast.leading.is_some() {
393                existing.leading = toast.leading;
394            } else if severity_changed {
395                existing.leading = None;
396            }
397            // `archive` flag tracks the latest call's intent. If
398            // the update sets `archive(false)` after an initial
399            // archived toast, the existing archive record stays in
400            // place but this and subsequent updates stop mirroring
401            // (no new `NotificationUpdate` is recorded). Apps that
402            // want the archive to keep capturing updates should
403            // leave `archive` at its default `true` across updates.
404            existing.archive = toast.archive;
405            let entry_id = existing.entry_id;
406            let revision = existing.revision.clone();
407            // Snapshot for archive mirror BEFORE dropping the
408            // RefCell borrow — the snapshot must be consistent with
409            // the mutation, and `archive.push(...)` cannot run while
410            // `inner` is still borrowed.
411            let archive_entry = if existing.archive {
412                Some(Self::entry_to_archive(existing))
413            } else {
414                None
415            };
416            drop(inner);
417            // An update of a toast still on screen, not a notice raised
418            // again: the archive records it only if it changes what the row
419            // shows, and leaves the row where it is.
420            if let (Some(archive), Some(entry)) = (self.archive.as_ref(), archive_entry) {
421                archive.push_update(entry);
422            }
423            revision.set(revision.get().wrapping_add(1));
424            self.bump_version();
425            let handle = ToastHandle::new(ToastHandleInner {
426                entry_id,
427                dismissed: std::cell::Cell::new(false),
428                registry: self.clone(),
429            });
430            return (handle, None);
431        }
432
433        let entry_id = inner.next_entry_id;
434        inner.next_entry_id += 1;
435
436        // Slot-pool admission, bucketed per route (decision: `max_visible`
437        // is a per-audience/per-window/per-broadcast budget, not a
438        // whole-app one) — a burst of one window's or one audience's
439        // toasts fills only ITS bucket, so it can never starve another
440        // window's or audience's admission.
441        let bucket_count = inner
442            .live_entries
443            .iter()
444            .filter(|e| e.route == resolved_route)
445            .count();
446        let at_capacity = bucket_count >= inner.max_visible;
447        if at_capacity {
448            match toast.priority {
449                ToastPriority::Normal => {
450                    // Drop the new entry; fire its on_dismiss with
451                    // SlotPoolFull synchronously to the caller via
452                    // the returned handle's "dropped" state.
453                    let cb = toast.on_dismiss.clone();
454                    drop(inner);
455                    let handle = ToastHandle::new(ToastHandleInner {
456                        entry_id,
457                        dismissed: std::cell::Cell::new(true),
458                        registry: self.clone(),
459                    });
460                    if let Some(cb) = cb {
461                        return (handle, Some((ToastDismissCause::SlotPoolFull, cb)));
462                    }
463                    return (handle, None);
464                }
465                ToastPriority::High | ToastPriority::Urgent => {
466                    // Evict the oldest Normal-priority entry WITHIN
467                    // this same route bucket — a High/Urgent arrival
468                    // for one audience must never bump an unrelated
469                    // window's/audience's Normal entry out of its slot.
470                    let evict_idx = inner.live_entries.iter().position(|e| {
471                        e.route == resolved_route && matches!(e.priority, ToastPriority::Normal)
472                    });
473                    if let Some(idx) = evict_idx {
474                        let removed = inner.live_entries.remove(idx).unwrap();
475                        if let Some(cb) = removed.on_dismiss.clone() {
476                            // Stash the bumped callback; the caller
477                            // returns it for the framework to drain
478                            // on the next pointer event.
479                            inner
480                                .pending_user_dismiss_callbacks
481                                .push((ToastDismissCause::SlotPoolFull, cb));
482                        }
483                    }
484                    // If no Normal entry to evict, the new toast still
485                    // joins the live set (queue grows above max_visible
486                    // until something dismisses).
487                }
488            }
489        }
490
491        let auto_dismiss = toast.auto_dismiss_after;
492        let entry = LiveEntry {
493            entry_id,
494            severity: toast.severity,
495            priority: toast.priority,
496            title: toast.title,
497            body: toast.body,
498            announcement: toast.announcement,
499            actions: Rc::new(toast.actions),
500            show_close_button: toast.show_close_button,
501            closable_on_escape: toast.closable_on_escape,
502            on_click: toast.on_click,
503            on_dismiss: toast.on_dismiss,
504            style_override: toast.style_override,
505            time_left: auto_dismiss,
506            timer_mark: Instant::now(),
507            focus_holders: 0,
508            revision: Signal::new(0),
509            held: false,
510            leading: toast.leading,
511            id: toast.id,
512            archive: toast.archive,
513            route: resolved_route,
514            // Starts at `Fits`; the body's own layout pass decides whether there is
515            // anything to disclose. See `crate::toast::body`.
516            body_state: Signal::new(0),
517        };
518        // Mirror to the archive BEFORE the entry is pushed to the
519        // live queue — that way an `archive(false)` toast (e.g. a
520        // quick "Copied!" feedback) is excluded, but a normal toast's
521        // archive record is populated even if a subsequent
522        // priority-eviction immediately knocks it out of the live set
523        // (the user still saw it; the log row is what survives).
524        //
525        // No live toast has this id, so this is a notice raised, even when
526        // an earlier one under the same id is still in the archive: `push`
527        // brings that row back to the top as unread rather than treating the
528        // arrival as a tick of a notice already gone.
529        if let Some(archive) = self.archive.as_ref() {
530            if entry.archive {
531                archive.push(Self::entry_to_archive(&entry));
532            }
533        }
534
535        inner.live_entries.push_back(entry);
536        drop(inner);
537        self.bump_version();
538
539        let handle = ToastHandle::new(ToastHandleInner {
540            entry_id,
541            dismissed: std::cell::Cell::new(false),
542            registry: self.clone(),
543        });
544        (handle, None)
545    }
546
547    /// Enqueue the framework's toast for a permanently-discarded
548    /// `teksilo-settings` write — the write-side counterpart of
549    /// `AppEvent::SettingsWriteFailed` (a `DebouncedWriter` gave up
550    /// after `MAX_WRITE_ATTEMPTS` retries, or was force-flushed still
551    /// failing at process teardown, and its queued patches were
552    /// dropped). This is data loss, not a status blip: `Error` severity
553    /// and persistent (no auto-dismiss), naming the file that failed.
554    ///
555    /// Framework-level and crate-internal to the join point: the
556    /// locale-validated strings can only live in teksilo-widgets
557    /// (`tr_widget!` resolves against *this* crate's own
558    /// `locales/*.ftl`), so the toast is built here rather than at the
559    /// call site. `teksilo::install_toast` (the umbrella crate — the
560    /// one place that sees both `teksilo-app`'s `AppEvent` and this
561    /// `ToastRegistry`) calls this from a
562    /// `TeksiloAppBuilder::register_app_event_observer` closure, so
563    /// every app with toast installed surfaces the loss automatically,
564    /// with no per-app wiring.
565    ///
566    /// No `EventContext` is available at the call site — this fires
567    /// from a background `AppEvent` observer, not a widget event
568    /// handler — so this goes straight to `enqueue` rather than
569    /// through `EventContextToastExt::show_toast`. The only situation
570    /// `enqueue` needs a context for is invoking the slot-pool-overflow
571    /// `on_dismiss` callback; this toast never sets one, so if the pool
572    /// is already full and this arrival evicts/drops an entry, there is
573    /// nothing behind that callback to lose — the overflow result is
574    /// dropped here deliberately, not silently.
575    pub fn show_settings_write_failed(
576        &self,
577        file_name: &str,
578        attempts: u32,
579        dropped_patches: usize,
580        message: &str,
581    ) {
582        let toast = Toast::error(tr_widget!(settings_write_failed_toast_title()))
583            .body(tr_widget!(settings_write_failed_toast_body(
584                file = file_name.to_string(),
585                attempts = attempts,
586                dropped = dropped_patches as i64,
587                message = message.to_string(),
588            )))
589            .persistent()
590            .priority(ToastPriority::High);
591        let (_handle, overflow) = self.enqueue(toast);
592        // Deliberately dropped — see the doc comment above.
593        drop(overflow);
594    }
595
596    /// Project a `LiveEntry` (the in-memory toast state) into a
597    /// `NotificationEntry` (the persistent archive shape). Drops
598    /// callbacks (`on_dismiss`, `on_click`, action callbacks) — only
599    /// `intent_name` on actions survives, used by the log for replay
600    /// through the existing intent dispatcher.
601    fn entry_to_archive(entry: &LiveEntry) -> NotificationEntry {
602        NotificationEntry {
603            id: 0, // overwritten by NotificationArchiveModel::push
604            severity: entry.severity,
605            priority: entry.priority,
606            title: entry.title.resolve_now(),
607            body: entry.body.as_ref().map(|b| b.resolve_now()),
608            actions: entry.actions.iter().map(Self::action_to_archive).collect(),
609            timestamp: jiff::Timestamp::now(),
610            group: None,
611            source: None,
612            read: false,
613            dedup_id: entry.id.clone(),
614            updates: Vec::new(),
615            route: entry.route,
616        }
617    }
618
619    fn action_to_archive(action: &ToastAction) -> ArchivedAction {
620        ArchivedAction {
621            label: action.label().to_string(),
622            intent_name: action.shortcut_id_ref().map(|s| s.to_string()),
623            style: match action.style_ref() {
624                ToastActionStyle::Link => ArchivedActionStyle::Link,
625                ToastActionStyle::Button { variant } => {
626                    use crate::button::ButtonVariant;
627                    match variant {
628                        ButtonVariant::Filled => ArchivedActionStyle::PrimaryButton,
629                        ButtonVariant::Destructive => ArchivedActionStyle::Destructive,
630                        _ => ArchivedActionStyle::SecondaryButton,
631                    }
632                }
633            },
634            closes_on_invoke: action.closes_toast_flag(),
635        }
636    }
637
638    /// Whether an entry with the given id is still in the live set.
639    pub(crate) fn is_entry_alive(&self, entry_id: u64) -> bool {
640        self.inner
641            .borrow()
642            .live_entries
643            .iter()
644            .any(|e| e.entry_id == entry_id)
645    }
646
647    /// Remove the entry from the live set and queue its on_dismiss
648    /// callback to fire from `ctx`. Called from event handlers
649    /// (close-click, action-invoked, escape, programmatic).
650    pub(crate) fn dismiss_entry(
651        &self,
652        entry_id: u64,
653        cause: ToastDismissCause,
654        ctx: &mut EventContext,
655    ) {
656        let removed = {
657            let mut inner = self.inner.borrow_mut();
658            let idx = inner
659                .live_entries
660                .iter()
661                .position(|e| e.entry_id == entry_id);
662            idx.and_then(|i| inner.live_entries.remove(i))
663        };
664        if let Some(entry) = removed {
665            self.bump_version();
666            if let Some(cb) = entry.on_dismiss.clone() {
667                cb(cause, ctx);
668            }
669        }
670    }
671
672    /// Same as `dismiss_entry` but defers the user callback to a
673    /// later pointer event — used by the frame-tick timer path which
674    /// doesn't have an `EventContext`.
675    pub(crate) fn dismiss_entry_deferred(&self, entry_id: u64, cause: ToastDismissCause) {
676        let removed = {
677            let mut inner = self.inner.borrow_mut();
678            let idx = inner
679                .live_entries
680                .iter()
681                .position(|e| e.entry_id == entry_id);
682            idx.and_then(|i| inner.live_entries.remove(i))
683        };
684        if let Some(entry) = removed
685            && let Some(cb) = entry.on_dismiss.clone()
686        {
687            self.inner
688                .borrow_mut()
689                .pending_user_dismiss_callbacks
690                .push((cause, cb));
691        }
692        self.bump_version();
693    }
694
695    /// Drain pending dismiss callbacks accumulated from timer-driven
696    /// expiries. Called by the host's `on_pointer_event` handler so
697    /// the user callbacks fire with a live `EventContext`.
698    pub(crate) fn drain_pending_dismiss_callbacks(&self, ctx: &mut EventContext) {
699        let drained: Vec<_> = {
700            let mut inner = self.inner.borrow_mut();
701            std::mem::take(&mut inner.pending_user_dismiss_callbacks)
702        };
703        for (cause, cb) in drained {
704            cb(cause, ctx);
705        }
706    }
707
708    /// Mark whether a pointer is holding the toast with `entry_id` down.
709    ///
710    /// Called from the surface's binding on the framework press signal, so the
711    /// release **and** a cancelled contact both clear it without the surface
712    /// having to notice either. Silently ignores an entry that has already gone.
713    pub(crate) fn set_entry_held(&self, entry_id: u64, held: bool) {
714        let changed = self
715            .inner
716            .borrow()
717            .live_entries
718            .iter()
719            .any(|e| e.entry_id == entry_id && e.held != held);
720        if !changed {
721            return;
722        }
723        // As for focus: the hold starts and ends at this instant.
724        self.settle_timers(Instant::now());
725        let mut inner = self.inner.borrow_mut();
726        if let Some(entry) = inner
727            .live_entries
728            .iter_mut()
729            .find(|e| e.entry_id == entry_id)
730        {
731            entry.held = held;
732        }
733    }
734
735    /// One of the toast's surfaces reports that keyboard focus arrived on it
736    /// or inside it (`true`), or left (`false`). A surface reports each change
737    /// once, and reports focus leaving when it is dropped with focus inside,
738    /// as a closed window's tree is, with no focus change. Silently ignores an
739    /// entry that has already gone.
740    ///
741    /// The timers are brought up to date first, so a pause starts, and ends,
742    /// at the moment focus moved: time spent on a toast is never charged to it
743    /// later, and time spent elsewhere is never forgiven.
744    pub(crate) fn set_entry_focused(&self, entry_id: u64, focused: bool) {
745        if !self.is_entry_alive(entry_id) {
746            return;
747        }
748        self.settle_timers(Instant::now());
749        if let Some(entry) = self
750            .inner
751            .borrow_mut()
752            .live_entries
753            .iter_mut()
754            .find(|e| e.entry_id == entry_id)
755        {
756            entry.focus_holders = if focused {
757                entry.focus_holders.saturating_add(1)
758            } else {
759                entry.focus_holders.saturating_sub(1)
760            };
761        }
762    }
763
764    /// Whether any live toast is being held down, or holds keyboard focus.
765    ///
766    /// Group-wide, matching the hover refcount: holding one toast of a stack
767    /// holds the stack, because dismissing the ones above or below it would
768    /// move the thing being read.
769    pub(crate) fn any_held(&self) -> bool {
770        self.inner
771            .borrow()
772            .live_entries
773            .iter()
774            .any(|e| e.held || e.focus_holders > 0)
775    }
776
777    /// Whether every timer stands still right now: a pointer rests on the
778    /// stack (with the group pause on), or a toast is held or focused.
779    fn timers_paused(&self) -> bool {
780        (self.pause_on_hover_group() && self.hover_count.get() > 0) || self.any_held()
781    }
782
783    /// Bring every timer up to `now` without expiring anything: charge the
784    /// time since each entry's mark unless the timers stand still, then move
785    /// every mark to `now`. Called when a pause starts or ends.
786    fn settle_timers(&self, now: Instant) {
787        let paused = self.timers_paused();
788        let mut inner = self.inner.borrow_mut();
789        for entry in inner.live_entries.iter_mut() {
790            if !paused && let Some(remaining) = entry.time_left.as_mut() {
791                *remaining =
792                    remaining.saturating_sub(now.saturating_duration_since(entry.timer_mark));
793            }
794            entry.timer_mark = now;
795        }
796    }
797
798    /// Tick the per-entry timers up to `now`, each from its own mark. While
799    /// the timers stand still (see [`Self::timers_paused`]) nothing is charged
800    /// and the marks move to `now`. Returns `true` if at least one entry
801    /// expired. Called from the host's frame-tick effect.
802    pub(crate) fn tick_timers_at(&self, now: Instant) -> bool {
803        self.settle_timers(now);
804        let expired: Vec<u64> = self
805            .inner
806            .borrow()
807            .live_entries
808            .iter()
809            .filter(|e| e.time_left.is_some_and(|left| left.is_zero()))
810            .map(|e| e.entry_id)
811            .collect();
812        let any = !expired.is_empty();
813        for entry_id in expired {
814            self.dismiss_entry_deferred(entry_id, ToastDismissCause::Timeout);
815        }
816        any
817    }
818
819    /// Tick the per-entry timers by `dt`. When `paused` is true (any
820    /// surface is hovered), or any surface is being held down or holds
821    /// keyboard focus, this is a no-op. Returns `true`
822    /// if at least one entry expired (host then bumps the version
823    /// signal and rebuilds, dropping the surfaces). The host ticks with
824    /// [`Self::tick_timers_at`]; this charges every entry the same `dt`.
825    pub(crate) fn tick_timers(&self, dt: Duration, paused: bool) -> bool {
826        if paused || self.any_held() {
827            return false;
828        }
829        let mut expired = Vec::new();
830        {
831            let mut inner = self.inner.borrow_mut();
832            for entry in inner.live_entries.iter_mut() {
833                if let Some(remaining) = entry.time_left.as_mut() {
834                    *remaining = remaining.saturating_sub(dt);
835                    if remaining.is_zero() {
836                        expired.push(entry.entry_id);
837                    }
838                }
839            }
840        }
841        let any = !expired.is_empty();
842        for entry_id in expired {
843            self.dismiss_entry_deferred(entry_id, ToastDismissCause::Timeout);
844        }
845        any
846    }
847
848    /// Whether any live entry has a finite, still-running auto-dismiss
849    /// timer. The host gates its per-frame `frame_tick` subscription on
850    /// this: with no running timer (the queue is empty, or every live
851    /// toast is sticky / `time_left == None`) there is nothing to
852    /// decrement each frame, so the host drops the subscription and lets
853    /// the event loop sleep. Without this gate an empty toast host kept
854    /// the loop awake at ~60 fps forever (a steady idle-CPU drain).
855    pub(crate) fn has_running_timers(&self) -> bool {
856        self.inner
857            .borrow()
858            .live_entries
859            .iter()
860            .any(|e| e.time_left.is_some())
861    }
862
863    /// Smallest remaining auto-dismiss duration among live timed
864    /// toasts as of `now`, or `None` if no toast has a running timer. The
865    /// host uses this to schedule a single `wake_at` deadline at the soonest
866    /// expiry instead of polling every frame — so a visible-but-idle
867    /// toast lets the event loop sleep.
868    pub(crate) fn min_running_timer_at(&self, now: Instant) -> Option<Duration> {
869        let paused = self.timers_paused();
870        self.inner
871            .borrow()
872            .live_entries
873            .iter()
874            .filter_map(|e| {
875                let left = e.time_left?;
876                Some(if paused {
877                    left
878                } else {
879                    left.saturating_sub(now.saturating_duration_since(e.timer_mark))
880                })
881            })
882            .min()
883    }
884
885    /// The entry's current data, as its surface renders it.
886    pub(crate) fn surface_data(
887        &self,
888        entry_id: u64,
889    ) -> Option<(super::surface::ToastSurfaceData, bool)> {
890        self.with_entry(entry_id, |e| {
891            (
892                super::surface::ToastSurfaceData {
893                    entry_id: e.entry_id,
894                    severity: e.severity,
895                    priority: e.priority,
896                    title: e.title.clone(),
897                    body: e.body.clone(),
898                    announcement: e.announcement.clone(),
899                    actions: e.actions.clone(),
900                    show_close_button: e.show_close_button,
901                    on_click: e.on_click.clone(),
902                    style_override: e.style_override.clone(),
903                    // Cloned, not re-created: the clone shares the entry's state,
904                    // which is what keeps an unfolded body unfolded.
905                    body_state: e.body_state.clone(),
906                },
907                e.closable_on_escape,
908            )
909        })
910    }
911
912    /// The signal an update in place bumps for `entry_id`.
913    pub(crate) fn entry_revision(&self, entry_id: u64) -> Option<Signal<u64>> {
914        self.with_entry(entry_id, |e| e.revision.clone())
915    }
916
917    /// Read-only snapshot of live entry ids — the host's `build()`
918    /// uses this to know how many surfaces to construct, in what
919    /// order. The actual entry data is read via `with_entry`.
920    pub(crate) fn live_entry_ids(&self) -> Vec<u64> {
921        self.inner
922            .borrow()
923            .live_entries
924            .iter()
925            .map(|e| e.entry_id)
926            .collect()
927    }
928
929    pub(crate) fn with_entry<R>(
930        &self,
931        entry_id: u64,
932        f: impl FnOnce(&LiveEntry) -> R,
933    ) -> Option<R> {
934        self.inner
935            .borrow()
936            .live_entries
937            .iter()
938            .find(|e| e.entry_id == entry_id)
939            .map(f)
940    }
941
942    /// Cancel an entry's auto-dismiss timer, making it persistent for the rest of its
943    /// life. Idempotent; a no-op for an entry that was already persistent or has gone.
944    ///
945    /// Called when a reader unfolds a clamped body. Hovering already pauses the timer, so
946    /// the toast survives while the pointer rests on it — but a reader who unfolds three
947    /// lines into ten and then moves the mouse away to read comfortably would otherwise
948    /// watch it vanish mid-sentence, which is precisely the frustration the disclosure
949    /// exists to remove. Asking to see more is a clear statement that the toast is being
950    /// read; the close button (and the notification archive) remain the way out.
951    pub(crate) fn cancel_auto_dismiss(&self, entry_id: u64) {
952        let mut inner = self.inner.borrow_mut();
953        if let Some(entry) = inner
954            .live_entries
955            .iter_mut()
956            .find(|e| e.entry_id == entry_id)
957        {
958            entry.time_left = None;
959        }
960    }
961
962    /// Take the boxed `leading` widget out of the entry — call exactly
963    /// once per entry. Subsequent rebuilds get `None` and fall back
964    /// to the default severity glyph.
965    pub(crate) fn take_leading(&self, entry_id: u64) -> Option<Box<dyn Widget>> {
966        let mut inner = self.inner.borrow_mut();
967        inner
968            .live_entries
969            .iter_mut()
970            .find(|e| e.entry_id == entry_id)
971            .and_then(|e| e.leading.take())
972    }
973
974    pub(crate) fn pause_on_hover_group(&self) -> bool {
975        self.inner.borrow().pause_on_hover_group
976    }
977
978    /// Test-only: how many entries are currently live.
979    pub fn live_count(&self) -> usize {
980        self.inner.borrow().live_entries.len()
981    }
982}
983
984impl std::fmt::Debug for ToastRegistry {
985    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
986        let inner = self.inner.borrow();
987        f.debug_struct("ToastRegistry")
988            .field("live_count", &inner.live_entries.len())
989            .field("max_visible", &inner.max_visible)
990            .field(
991                "pending_callbacks",
992                &inner.pending_user_dismiss_callbacks.len(),
993            )
994            .field("hover_count", &self.hover_count.get())
995            .finish()
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use crate::toast::Toast;
1003    use crate::toast::host::ToastInstallOptions;
1004    use teksilo_i18n::lit;
1005
1006    fn registry() -> ToastRegistry {
1007        ToastRegistry::new(ToastInstallOptions {
1008            archive: None,
1009            ..ToastInstallOptions::default()
1010        })
1011    }
1012
1013    #[test]
1014    fn severity_change_drops_stale_custom_leading() {
1015        let r = registry();
1016        // A loading toast carries a Spinner as its custom leading.
1017        let _ = r.enqueue(Toast::loading(lit!("Working")).id("op"));
1018        let eid = r.live_entry_ids()[0];
1019        assert!(
1020            r.with_entry(eid, |e| e.leading.is_some()).unwrap(),
1021            "loading toast starts with a spinner leading"
1022        );
1023
1024        // Update-in-place to a success toast (different severity, no custom
1025        // leading) → the stale spinner must be cleared so the surface shows the
1026        // success glyph, not a spinner spinning under a "success" title.
1027        let _ = r.enqueue(Toast::success(lit!("Done")).id("op"));
1028        assert!(
1029            !r.with_entry(eid, |e| e.leading.is_some()).unwrap(),
1030            "a severity change without a new leading drops the stale spinner"
1031        );
1032    }
1033
1034    #[test]
1035    fn same_severity_text_update_keeps_leading() {
1036        let r = registry();
1037        // `loading` = Info severity + Spinner.
1038        let _ = r.enqueue(Toast::loading(lit!("0%")).id("op"));
1039        let eid = r.live_entry_ids()[0];
1040        // A same-severity (Info) text-only update with no custom leading keeps
1041        // the spinner — so a progress toast's spinner survives its text updates.
1042        let _ = r.enqueue(Toast::info(lit!("50%")).id("op"));
1043        assert!(
1044            r.with_entry(eid, |e| e.leading.is_some()).unwrap(),
1045            "a same-severity text update preserves the existing spinner"
1046        );
1047    }
1048
1049    #[test]
1050    fn show_settings_write_failed_enqueues_a_persistent_error_toast_naming_the_file() {
1051        // F3: this is the framework-level join that turns a permanently
1052        // discarded `teksilo-settings` write into something the user
1053        // actually sees. Assert on registry state (severity, persistence,
1054        // the file name landing in the resolved body), not pixels.
1055        let r = registry();
1056        r.show_settings_write_failed("window_state.toml", 5, 3, "disk full");
1057
1058        assert_eq!(r.live_count(), 1, "the failure enqueues exactly one toast");
1059        let eid = r.live_entry_ids()[0];
1060        r.with_entry(eid, |e| {
1061            assert_eq!(
1062                e.severity,
1063                ToastSeverity::Error,
1064                "settings data loss is Error severity, not a status blip"
1065            );
1066            assert!(
1067                e.time_left.is_none(),
1068                "the toast is persistent — no auto-dismiss for data loss"
1069            );
1070            let body = e.body.as_ref().expect("body must be set").resolve_now();
1071            assert!(
1072                body.contains("window_state.toml"),
1073                "the failing file's name must appear in the body: {body:?}"
1074            );
1075        })
1076        .unwrap();
1077    }
1078
1079    #[test]
1080    fn show_settings_write_failed_is_high_priority_and_survives_pool_pressure() {
1081        // `show_settings_write_failed`'s toast is High priority (data
1082        // loss deserves to be seen even when the pool is already full of
1083        // routine Normal-priority toasts) and never attaches its own
1084        // `on_dismiss`, so there's nothing behind the evicted entry's
1085        // slot-pool-overflow callback path to lose. This proves the call
1086        // completes cleanly under pool pressure (no `EventContext`
1087        // available to invoke any overflow callback with) and that the
1088        // settings-failure toast wins the slot rather than being dropped
1089        // like a Normal-priority arrival would be.
1090        let r = ToastRegistry::new(ToastInstallOptions {
1091            archive: None,
1092            max_visible: 1,
1093            ..ToastInstallOptions::default()
1094        });
1095        let _ = r.enqueue(Toast::info(lit!("already here")));
1096        assert_eq!(r.live_count(), 1);
1097
1098        r.show_settings_write_failed("settings.toml", 5, 1, "read-only filesystem");
1099
1100        assert_eq!(
1101            r.live_count(),
1102            1,
1103            "High priority evicts the oldest Normal entry rather than growing past capacity"
1104        );
1105        let eid = r.live_entry_ids()[0];
1106        r.with_entry(eid, |e| {
1107            assert_eq!(
1108                e.severity,
1109                ToastSeverity::Error,
1110                "the settings-failure toast must win the slot, not the evicted one"
1111            );
1112        })
1113        .unwrap();
1114    }
1115
1116    // ----- F3: `forget_window` -----
1117
1118    #[test]
1119    fn forget_window_removes_the_windows_audience_entry() {
1120        let r = registry();
1121        let w = TeksiloWindowId::new(1);
1122
1123        r.set_window_audience(w, Some(ToastAudience::new(42)));
1124        assert!(r.window_audiences.borrow().contains_key(&w));
1125
1126        r.forget_window(w);
1127
1128        assert!(
1129            !r.window_audiences.borrow().contains_key(&w),
1130            "forget_window must remove the window's audience-map entry"
1131        );
1132    }
1133
1134    /// Audience assignment is per-window state and stays that way.
1135    /// Rebuild *notification* is not: one `version_signal` reaches
1136    /// every window's `ToastHost`, because each window's own
1137    /// `BindingRegistry` tracks the generation it last reconciled.
1138    #[test]
1139    fn one_version_signal_notifies_every_windows_host() {
1140        use teksilo_core::binding::{BindingLevel, BindingRegistry};
1141        use teksilo_core::widget_id::WidgetId;
1142
1143        let r = registry();
1144        let host: WidgetId = slotmap::KeyData::from_ffi(1).into();
1145        let windows: Vec<BindingRegistry> = (0..3).map(|_| BindingRegistry::new()).collect();
1146        for reg in &windows {
1147            r.version_signal().bind_to(host, reg, BindingLevel::Rebuild);
1148        }
1149
1150        let (_handle, _overflow) = r.enqueue(Toast::info(lit!("hello")));
1151
1152        for (i, reg) in windows.iter().enumerate() {
1153            assert!(
1154                reg.any_dirty(),
1155                "window {i}'s host missed the enqueue — an earlier window \
1156                 looking must not consume it"
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn forget_window_on_an_unknown_window_is_a_safe_no_op() {
1163        let r = registry();
1164        let known = TeksiloWindowId::new(1);
1165        let unknown = TeksiloWindowId::new(999);
1166        r.set_window_audience(known, Some(ToastAudience::new(7)));
1167
1168        // Forgetting a window that was never registered must not
1169        // panic and must not disturb any other window's entries.
1170        r.forget_window(unknown);
1171
1172        assert!(
1173            r.window_audiences.borrow().contains_key(&known),
1174            "an unrelated window's entry must survive forgetting a different, unknown window"
1175        );
1176
1177        // Forgetting it twice (idempotent teardown, or a duplicate
1178        // teardown hook call) is equally a safe no-op.
1179        r.forget_window(known);
1180        r.forget_window(known);
1181        assert!(!r.window_audiences.borrow().contains_key(&known));
1182    }
1183
1184    #[test]
1185    fn forgetting_a_windows_audience_does_not_panic_a_still_live_toast_routed_to_it() {
1186        // Reproduces the exact shape `forget_window` must handle
1187        // safely: a window is torn down (and forgotten) while a toast
1188        // that was routed to its audience is still live in the queue.
1189        // Nothing about tearing down the window's map entries should
1190        // reach into, or otherwise disturb, unrelated live entries —
1191        // the entry keeps existing with its already-resolved route
1192        // until something explicitly dismisses it.
1193        let r = registry();
1194        let w = TeksiloWindowId::new(1);
1195        let audience = ToastAudience::new(11);
1196        r.set_window_audience(w, Some(audience));
1197
1198        let (_handle, overflow) = r.enqueue(Toast::info(lit!("still going")).target(audience));
1199        assert!(overflow.is_none());
1200        assert_eq!(r.live_count(), 1);
1201
1202        // The window closes: app code forgets it.
1203        r.forget_window(w);
1204
1205        // The live entry (already routed to the audience, independent
1206        // of the now-removed per-window map entries) is untouched.
1207        assert_eq!(
1208            r.live_count(),
1209            1,
1210            "forgetting the window must not evict or otherwise touch live entries"
1211        );
1212        let eid = r.live_entry_ids()[0];
1213        r.with_entry(eid, |e| {
1214            assert_eq!(e.route, ToastRoute::Audience(audience));
1215        })
1216        .unwrap();
1217
1218        // A further enqueue against the now-forgotten window's old
1219        // audience still works fine (routing lives on the entry /
1220        // resolved at enqueue time, not on the per-window maps) —
1221        // proves nothing panics or gets corrupted by the map removal.
1222        let (_h2, overflow2) = r.enqueue(Toast::info(lit!("another one")).target(audience));
1223        assert!(overflow2.is_none());
1224        assert_eq!(r.live_count(), 2);
1225
1226        // And re-deriving a signal for the forgotten window id after
1227        // the fact is safe too — get-or-create transparently
1228        // allocates a fresh one (documented behaviour, not a panic).
1229        let fresh = r.window_audience_signal(w);
1230        assert!(
1231            fresh.get().is_none(),
1232            "a re-created signal starts fresh, not with the old audience"
1233        );
1234    }
1235
1236    /// An archive mirrors every enqueue, and its own version signal
1237    /// must reach every window too — the bell in window B updates for
1238    /// a toast raised from window A. Covers the registry → archive
1239    /// hand-off, which is where the mirrored bump originates.
1240    #[test]
1241    fn a_mirrored_push_notifies_every_windows_bell() {
1242        use teksilo_core::binding::{BindingLevel, BindingRegistry};
1243        use teksilo_core::widget_id::WidgetId;
1244
1245        let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1246        let r = ToastRegistry::with_archive(ToastInstallOptions::default(), archive.clone());
1247        let bell: WidgetId = slotmap::KeyData::from_ffi(2).into();
1248        let windows: Vec<BindingRegistry> = (0..2).map(|_| BindingRegistry::new()).collect();
1249        for reg in &windows {
1250            archive
1251                .version_signal()
1252                .bind_to(bell, reg, BindingLevel::Rebuild);
1253        }
1254
1255        let (_h, _overflow) = r.enqueue(Toast::info(lit!("mirrored")));
1256
1257        for (i, reg) in windows.iter().enumerate() {
1258            assert!(
1259                reg.any_dirty(),
1260                "window {i}'s bell missed the mirrored push"
1261            );
1262        }
1263    }
1264
1265    #[test]
1266    fn forget_window_without_an_archive_configured_does_not_panic() {
1267        // Registries built via `new` (no archive) must tear down as a
1268        // safe no-op, not unwrap a `None`.
1269        let r = registry();
1270        assert!(r.archive().is_none());
1271        let w = TeksiloWindowId::new(1);
1272        r.set_window_audience(w, Some(ToastAudience::new(3)));
1273
1274        r.forget_window(w);
1275
1276        assert!(!r.window_audiences.borrow().contains_key(&w));
1277    }
1278}