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