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}