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}