teksilo_widgets/toast/host.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ToastHost` — invisible sibling widget that owns the toast queue.
5//!
6//! Installed by `install_toast(opts)` in the `teksilo` umbrella. The
7//! umbrella's `TeksiloAppBuilderToastExt::install_toast` registers a
8//! `DefaultPostRoot` closure that wraps
9//! every window's root with a `ZStack` of `[user_root, ToastHost]`.
10//! The host renders its toast surfaces as direct children, positioned
11//! absolutely at the configured viewport corner. The wrapping ZStack
12//! ensures toasts paint above the user content; the host itself fills
13//! the viewport (so its children — the toasts — have absolute screen
14//! coordinates to anchor against) and is `event_pass_through` outside
15//! the toast bounds so the user can still interact with content below.
16//!
17//! No overlay system involvement — toasts are regular widgets in the
18//! arena. The host owns the per-frame timer + hover-pause; expired
19//! entries are removed from the registry's queue, the version signal
20//! is bumped, the host rebuilds, the surface widgets are destroyed.
21//!
22//! Routing: each host filters `live_entry_ids()` down to entries whose
23//! `ToastRoute` matches its own window id / assigned audience, or that
24//! are `Broadcast`. Every host binds the SAME
25//! `ToastRegistry::version_signal` at `BindingLevel::Rebuild` — one
26//! signal reaches N windows, because each window's `WidgetTree` owns
27//! its own `BindingRegistry` and that registry remembers the
28//! generation it last reconciled (see
29//! `teksilo_core::binding::BindingRegistry`). A host that matches
30//! nothing in a given rebuild just produces zero new surfaces, which
31//! is cheap and lets one shared queue serve every window without a
32//! per-window registry.
33
34use std::cell::{Cell, RefCell};
35use std::rc::Rc;
36use std::time::{Duration, Instant};
37
38use teksilo_canvas::{Rect, SizeProposal, Vec2};
39use teksilo_core::accessibility::AccessNodeBuilder;
40use teksilo_core::binding::BindingLevel;
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
43use teksilo_core::widget_builder::HandlerSet;
44use teksilo_core::widget_id::WidgetId;
45use teksilo_tokens::Corner;
46
47use crate::notification::NotificationArchive;
48use crate::toast::registry::ToastRegistry;
49use crate::toast::surface::{ToastSurface, ToastSurfaceData};
50use crate::toast::{ToastAudience, ToastRoute};
51
52/// Configuration for the installed [`ToastHost`]. Passed to
53/// `install_toast` in the `teksilo` umbrella crate.
54#[derive(Clone, Debug)]
55pub struct ToastInstallOptions {
56 /// Which viewport corner the toasts anchor to. Default
57 /// `BottomTrailing` (matches JetBrains IntelliJ and Windows
58 /// system tray conventions). Under RTL, Trailing flips to the
59 /// physical left edge.
60 pub corner: Corner,
61 /// Outer margin from the corner — (x, y) in logical pixels.
62 /// Default `(24, 24)`.
63 pub margin: Vec2,
64 /// Vertical gap between stacked toasts. Default `8.0`.
65 pub gap: f32,
66 /// Maximum simultaneously visible toasts. Default `5`. Normal
67 /// priority overflow drops; High / Urgent evict the oldest Normal.
68 pub max_visible: usize,
69 /// Fixed width for each toast surface. Default `380.0` (matches
70 /// IntelliJ balloon width).
71 pub entry_width: f32,
72 /// When true (default), hovering any toast pauses every timer.
73 /// When false, only the hovered toast pauses (libadwaita
74 /// behaviour).
75 pub pause_on_hover_group: bool,
76 /// Notification archive — `None` disables archival entirely
77 /// (apps don't need a NotificationLog). Default:
78 /// `Some(NotificationArchive::persistent(ARCHIVE_FILE_NAME))`,
79 /// which writes to `<config>/notifications.toml` via
80 /// `PersistedListModel`. Apps that don't have `AppPaths`
81 /// configured (`SettingsBundle` not installed) should explicitly
82 /// override to `Some(NotificationArchive::in_memory())` or
83 /// `None` — `Persistent` will fail at install time without a
84 /// `config_dir`.
85 pub archive: Option<NotificationArchive>,
86 /// Audience to seed this window with, the first time its
87 /// `ToastHost` observes a real window (see `ToastHost::build`).
88 /// Default `None` — the window starts unassigned (its toasts/bell
89 /// show only origin-window-routed and broadcast entries) until app
90 /// code calls `ToastRegistry::set_window_audience` explicitly.
91 ///
92 /// Because `install_toast`'s `DefaultPostRoot` closure clones the
93 /// SAME `ToastInstallOptions` for every window it wraps, this
94 /// field gives every window the SAME initial audience — it is NOT
95 /// a way to give distinct windows distinct starting audiences
96 /// through the shared install path. Apps that need per-window
97 /// initial audiences should call `set_window_audience` right after
98 /// creating each window instead (typically from the window-opened
99 /// handler that also knows which document/session that window is
100 /// showing).
101 pub initial_audience: Option<ToastAudience>,
102}
103
104impl Default for ToastInstallOptions {
105 fn default() -> Self {
106 Self {
107 corner: Corner::BottomTrailing,
108 margin: Vec2::new(24.0, 24.0),
109 gap: 8.0,
110 max_visible: 5,
111 entry_width: 380.0,
112 pause_on_hover_group: true,
113 archive: Some(NotificationArchive::persistent(
114 crate::notification::ARCHIVE_FILE_NAME,
115 )),
116 initial_audience: None,
117 }
118 }
119}
120
121/// Invisible sibling widget that owns the toast queue. Installed once
122/// per window by the `install_toast` extension trait via a
123/// `DefaultPostRoot` closure (see `teksilo::toast_install`).
124///
125/// Renders its toast surfaces as direct children positioned at the
126/// configured corner. Use `ZStack::new().child(user_root).child(host)`
127/// to put the host above the user content.
128pub struct ToastHost {
129 registry: ToastRegistry,
130 options: ToastInstallOptions,
131 /// Toast surface ids matched 1:1 with the registry's live entry
132 /// ids at the time of the last `build()`. Used by `place_children`
133 /// to know the placement order.
134 toast_surface_ids: Vec<WidgetId>,
135 /// `Instant` of the last timer tick — used to compute `dt`. The
136 /// auto-dismiss timer is driven by a `wake_at` deadline (see
137 /// `build`), not a per-frame subscription, so a visible toast does
138 /// not pin the event loop at 60 fps.
139 last_tick_at: Rc<RefCell<Option<Instant>>>,
140 /// Set true once any pointer-event handler has been attached so
141 /// subsequent rebuilds don't re-attach. The handler drives the
142 /// pending dismiss-callback drain.
143 has_pending_drain_handler: Cell<bool>,
144 /// Set true once `options.initial_audience` has been applied to
145 /// this host's window (the first build that observes a real
146 /// window). Guards against re-seeding a window that app code has
147 /// since deliberately reset to `None` — without this flag, every
148 /// rebuild would see the signal back at `None` and re-apply the
149 /// stale initial value, silently undoing an intentional clear.
150 initial_audience_applied: Cell<bool>,
151}
152
153impl ToastHost {
154 /// Construct a host bound to the given registry. Add to the tree
155 /// alongside the user root inside a `ZStack`.
156 pub fn new(registry: ToastRegistry, options: ToastInstallOptions) -> Self {
157 Self {
158 registry,
159 options,
160 toast_surface_ids: Vec::new(),
161 last_tick_at: Rc::new(RefCell::new(None)),
162 has_pending_drain_handler: Cell::new(false),
163 initial_audience_applied: Cell::new(false),
164 }
165 }
166
167 /// Backwards-compatibility alias for ergonomic post-root
168 /// installation: an app that already has a wrapping ZStack can
169 /// construct a host via the standalone `new(...)`. This helper
170 /// returns a fresh wrapper that uses `ZStack` internally — but
171 /// since the wrapping is owned by `install_toast` itself, this is
172 /// rarely called by user code.
173 pub fn wrapping(
174 _user_root: WidgetId,
175 registry: ToastRegistry,
176 options: ToastInstallOptions,
177 ) -> Self {
178 // Legacy shape kept for the existing tests + initial install
179 // call site; the actual ZStack wrapping is performed in the
180 // install closure (which calls `new` on the host alongside
181 // the user-root id). The argument is documented but ignored.
182 Self::new(registry, options)
183 }
184}
185
186impl std::fmt::Debug for ToastHost {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("ToastHost")
189 .field("toast_count", &self.toast_surface_ids.len())
190 .field("options", &self.options)
191 .finish()
192 }
193}
194
195/// While a hovered toast freezes its countdown we can't know in advance
196/// when the pointer will leave, so we poll on this coarse interval just
197/// to notice the un-hover. Hovering is a brief, deliberate user action,
198/// so ~8 Hz here is negligible (and only while actually hovering).
199const HOVER_POLL_INTERVAL: Duration = Duration::from_millis(120);
200/// Floor on the scheduled wake delay so an almost-expired toast can't
201/// schedule a zero/near-zero deadline and busy-loop for one frame.
202const MIN_WAKE_DELAY: Duration = Duration::from_millis(8);
203
204/// (Re)arm the auto-dismiss deadline. When a toast is hovered (and
205/// hover-pause is on) the countdown is frozen, so we schedule a short
206/// poll to detect the un-hover; otherwise we sleep right up to the
207/// soonest expiry. Merges with any existing earlier deadline so we never
208/// push another widget's pending `wake_at` out.
209fn schedule_toast_wake(
210 registry: &ToastRegistry,
211 wake_at: &Rc<Cell<Option<Instant>>>,
212 pause_on_hover_group: bool,
213 now: Instant,
214) {
215 let paused = pause_on_hover_group && registry.hover_count_signal().get() > 0;
216 let delay = if paused {
217 HOVER_POLL_INTERVAL
218 } else {
219 match registry.min_running_timer() {
220 Some(remaining) => remaining.max(MIN_WAKE_DELAY),
221 None => return, // nothing left to wait for
222 }
223 };
224 let target = now + delay;
225 let merged = match wake_at.get() {
226 Some(existing) if existing <= target => existing,
227 _ => target,
228 };
229 wake_at.set(Some(merged));
230}
231
232impl Widget for ToastHost {
233 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
234 // This window's identity + audience — the two things a live
235 // entry's `ToastRoute` is matched against below. `ctx.window()`
236 // is `None` in a headless tree with no `set_window_state` call
237 // (unit tests); such a host simply never matches `Window(_)`
238 // or `Audience(_)` routes, only `Broadcast` — the same
239 // single-window-app behaviour as before this feature existed.
240 let my_window = ctx.window().map(|w| w.id());
241
242 // Rebuild on any queue mutation (show, dismiss, timer expiry).
243 // One signal, shared by every window's host: this window's own
244 // `BindingRegistry` tracks the generation it last reconciled,
245 // so no other window's reconcile can consume the notification
246 // (see `ToastRegistry::version_signal`). Routing/filtering is
247 // decided below at render time, so every host wants every
248 // mutation regardless of which window it targets.
249 self.registry.version_signal().bind_to(
250 ctx.self_id(),
251 ctx.binding_registry(),
252 BindingLevel::Rebuild,
253 );
254 let my_audience_signal = my_window.map(|w| self.registry.window_audience_signal(w));
255 if let Some(sig) = &my_audience_signal {
256 // Seed `options.initial_audience` exactly once — see the
257 // field's doc comment for why this can't just re-run every
258 // build (it would fight an intentional later reset to
259 // `None`).
260 if !self.initial_audience_applied.get() {
261 if let Some(initial) = self.options.initial_audience {
262 sig.set(Some(initial));
263 }
264 self.initial_audience_applied.set(true);
265 }
266 sig.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
267 }
268 let my_audience: Option<ToastAudience> = my_audience_signal.and_then(|s| s.get());
269
270 // Build one ToastSurface per live entry THAT ROUTES HERE. Each
271 // rebuild creates fresh ToastSurface widget instances — old
272 // surfaces are torn down by the framework (no
273 // preserve_children). The route check must happen before
274 // `take_leading` (a take-once side effect) runs for an entry —
275 // an entry this host skips must be left completely untouched
276 // so whichever host it DOES route to still sees its leading
277 // widget intact.
278 let entry_ids = self.registry.live_entry_ids();
279 let mut surface_ids = Vec::with_capacity(entry_ids.len());
280 for entry_id in &entry_ids {
281 let route_matches = self
282 .registry
283 .with_entry(*entry_id, |e| match e.route {
284 ToastRoute::Broadcast => true,
285 ToastRoute::Window(w) => my_window == Some(w),
286 ToastRoute::Audience(a) => my_audience == Some(a),
287 })
288 .unwrap_or(false);
289 if !route_matches {
290 continue;
291 }
292 let Some(data) = self.registry.with_entry(*entry_id, |e| ToastSurfaceData {
293 entry_id: e.entry_id,
294 severity: e.severity,
295 priority: e.priority,
296 title: e.title.clone(),
297 body: e.body.clone(),
298 announcement: e.announcement.clone(),
299 actions: e.actions.clone(),
300 show_close_button: e.show_close_button,
301 on_click: e.on_click.clone(),
302 style_override: e.style_override.clone(),
303 // Cloned, not re-created: the clone shares the entry's state, which is
304 // what keeps an unfolded body unfolded across this very rebuild.
305 body_state: e.body_state.clone(),
306 }) else {
307 continue;
308 };
309 let leading = self.registry.take_leading(*entry_id);
310 let closable_on_escape = self
311 .registry
312 .with_entry(*entry_id, |e| e.closable_on_escape)
313 .unwrap_or(true);
314 let surface =
315 ToastSurface::new(data, leading, self.registry.clone(), closable_on_escape);
316 surface_ids.push(ctx.add(surface));
317 }
318
319 // Auto-dismiss timer. Driven by a one-shot `wake_at` deadline,
320 // NOT a per-frame subscription: a visible toast lets the event
321 // loop SLEEP until its soonest expiry instead of repainting the
322 // whole window at 60 fps just to decrement an invisible counter.
323 // `build()` re-runs on every queue mutation (the `version_signal`
324 // Rebuild binding above), so the deadline is re-armed whenever a
325 // timed toast appears and torn down when the last one expires.
326 // (Spinners inside `loading` toasts animate via their own
327 // AnimatedQuad path and keep ticking regardless of this.)
328 if self.registry.has_running_timers() {
329 let registry_for_tick = self.registry.clone();
330 let last_tick_at = self.last_tick_at.clone();
331 let wake_at = ctx.wake_at_handle();
332 let pause_on_hover_group = self.options.pause_on_hover_group;
333
334 // Stamp the dt baseline at arm time so the first deadline wake
335 // measures a real elapsed delta. (The effect consults
336 // wall-clock, not the frame-tick signal — whose delta is
337 // clamped to 0.1 s and would under-count a multi-second sleep.)
338 if last_tick_at.borrow().is_none() {
339 *last_tick_at.borrow_mut() = Some(Instant::now());
340 }
341
342 let wake_for_tick = wake_at.clone();
343 ctx.effect(&ctx.frame_tick(), move |_delta_from_signal| {
344 let now = Instant::now();
345 let dt = {
346 let mut last = last_tick_at.borrow_mut();
347 let result = last
348 .map(|t| now.saturating_duration_since(t))
349 .unwrap_or_default();
350 *last = Some(now);
351 result
352 };
353 let paused =
354 pause_on_hover_group && registry_for_tick.hover_count_signal().get() > 0;
355 registry_for_tick.tick_timers(dt, paused);
356 // Re-arm for the next expiry. (An expiry dismisses via a
357 // version bump → rebuild, which re-arms too; rescheduling
358 // here also covers the case where an unrelated frame ran
359 // the effect before the deadline.)
360 if registry_for_tick.has_running_timers() {
361 schedule_toast_wake(
362 ®istry_for_tick,
363 &wake_for_tick,
364 pause_on_hover_group,
365 now,
366 );
367 }
368 });
369
370 // Arm the initial deadline so the loop wakes at expiry even if
371 // nothing else requests a frame in the meantime.
372 schedule_toast_wake(
373 &self.registry,
374 &wake_at,
375 pause_on_hover_group,
376 Instant::now(),
377 );
378 } else {
379 // No running timer: reset the dt baseline so the next timed
380 // toast measures from its own arrival, not from a stale
381 // timestamp left over from a previous toast session (which
382 // would otherwise instant-expire it on the first tick).
383 *self.last_tick_at.borrow_mut() = None;
384 }
385
386 // Pending-dismiss-callback drain handler (attached once).
387 //
388 // The host fills the whole viewport so its toast children can
389 // anchor at absolute corner coordinates, but it must NOT eat
390 // clicks meant for the user content below it in the wrapping
391 // `ZStack`. `event_pass_through(true)` makes the host
392 // transparent to hit-testing: its toast children are still
393 // hit-tested first (clicks on a toast land on the toast), but a
394 // click that misses every toast falls through to the user root
395 // instead of being swallowed by the host's full-viewport
396 // background. Without this, *all* pointer input is blocked.
397 if !self.has_pending_drain_handler.get() {
398 let registry_for_drain = self.registry.clone();
399 let handlers =
400 HandlerSet::new()
401 .event_pass_through(true)
402 .on_pointer_event(move |_event, ctx| {
403 registry_for_drain.drain_pending_dismiss_callbacks(ctx);
404 teksilo_core::event::EventResponse::Ignored
405 });
406 ctx.apply_self_handlers(handlers);
407 self.has_pending_drain_handler.set(true);
408 }
409
410 self.toast_surface_ids = surface_ids.clone();
411 surface_ids
412 }
413
414 fn layout_response(
415 &self,
416 proposal: SizeProposal,
417 _ctx: &LayoutContext,
418 ) -> teksilo_core::widget::LayoutResponse {
419 // Host fills the proposed viewport. `place_children` positions
420 // each toast surface at the configured corner.
421 proposal
422 .resolve(
423 proposal.width.unwrap_or(0.0),
424 proposal.height.unwrap_or(0.0),
425 )
426 .into()
427 }
428
429 fn place_children(
430 &self,
431 bounds: Rect,
432 proposal: SizeProposal,
433 children: &mut [WidgetPlacement],
434 ctx: &LayoutContext,
435 ) {
436 if children.is_empty() {
437 return;
438 }
439 let rtl = ctx.is_rtl();
440 let vw = proposal.width.unwrap_or(bounds.width);
441 let vh = proposal.height.unwrap_or(bounds.height);
442
443 // Probe each surface's natural size against the host's
444 // entry_width so all toasts share a uniform width but hug
445 // their natural height.
446 let mut surface_sizes = Vec::with_capacity(children.len());
447 for placement in children.iter() {
448 let resp = ctx
449 .child_size(
450 placement.id,
451 SizeProposal {
452 width: Some(self.options.entry_width),
453 height: None,
454 },
455 )
456 .unwrap_or_else(|| teksilo_canvas::Size::new(self.options.entry_width, 0.0));
457 surface_sizes.push(teksilo_canvas::Size::new(
458 self.options.entry_width,
459 resp.height,
460 ));
461 }
462
463 // For bottom corners, newer-at-bottom (closest to anchor).
464 // For top corners, newer-at-top. Iteration is FIFO by insertion;
465 // the offset of each entry from the corner = sum of subsequent
466 // entries' heights + gaps.
467 let len = children.len();
468 for i in 0..len {
469 let size = surface_sizes[i];
470 let mut stack_offset = self.options.margin.y;
471 for j in (i + 1)..len {
472 stack_offset += surface_sizes[j].height + self.options.gap;
473 }
474 let (x, y) = self.options.corner.resolve(
475 (size.width, size.height),
476 (vw, vh),
477 (self.options.margin.x, stack_offset),
478 rtl,
479 );
480 children[i].origin = teksilo_canvas::Point::new(x, y);
481 children[i].size = size;
482 }
483 }
484
485 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
486 // The host is invisible chrome — toasts contribute their own
487 // AT nodes as descendants. Mark generic + hidden so VoiceOver
488 // / NVDA don't insert a dead GenericContainer in the tree.
489 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
490 builder.set_hidden();
491 }
492
493 fn children(&self) -> Vec<WidgetId> {
494 self.toast_surface_ids.clone()
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::primitives::{Expand, FixedSize, VStack, ZStack};
502 use crate::toast::Toast;
503 use crate::toast::registry::ToastRegistry;
504 use teksilo_canvas::SizeProposal;
505 use teksilo_core::widget_tree::WidgetTree;
506 use teksilo_i18n::LocalizedString;
507
508 fn opts() -> ToastInstallOptions {
509 ToastInstallOptions {
510 archive: None,
511 ..ToastInstallOptions::default()
512 }
513 }
514
515 /// A user root smaller than the window (the common case: a VStack of
516 /// content that does not itself fill the height).
517 fn small_root() -> impl Widget {
518 VStack::new().child(
519 FixedSize::new()
520 .width(200.0)
521 .height(120.0)
522 .child(crate::primitives::Spacer::new()),
523 )
524 }
525
526 fn surface_bounds(structure: &str) -> (Rect, Rect) {
527 let o = opts();
528 let registry = ToastRegistry::new(o.clone());
529 registry.enqueue(Toast::info(LocalizedString::literal("Hello")));
530
531 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
532 let user_root = tree.add(small_root());
533 let host_id = tree.add(ToastHost::new(registry.clone(), o));
534 match structure {
535 // install_toast as shipped before the fix: ZStack { root, host }.
536 "bare" => {
537 tree.add(ZStack::new().add_child(user_root).add_child(host_id));
538 }
539 // install_toast with the Expand fill wrap: ZStack { Expand(root), host }.
540 "expand" => {
541 let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
542 tree.add(ZStack::new().add_child(filled).add_child(host_id));
543 }
544 _ => unreachable!(),
545 }
546
547 tree.layout(SizeProposal::exact(900.0, 600.0));
548
549 let host_bounds = tree.bounds(host_id);
550 let surfaces = tree.children(host_id);
551 assert_eq!(
552 surfaces.len(),
553 1,
554 "[{structure}] expected one toast surface"
555 );
556 (host_bounds, tree.bounds(surfaces[0]))
557 }
558
559 /// The host must fill the window and place its toast surface at the
560 /// bottom-trailing corner, fully on-screen — regardless of whether
561 /// the user root is wrapped in an `Expand`. This is the regression
562 /// guard for "no toast anywhere" / "toast off-screen".
563 #[test]
564 fn toast_surface_is_visible_at_bottom_right_with_and_without_expand() {
565 for structure in ["bare", "expand"] {
566 let (host_bounds, sb) = surface_bounds(structure);
567
568 assert!(
569 (host_bounds.width - 900.0).abs() < 0.5 && (host_bounds.height - 600.0).abs() < 0.5,
570 "[{structure}] host should fill window, got {host_bounds:?}"
571 );
572 assert!(sb.height > 1.0, "[{structure}] surface collapsed: {sb:?}");
573 assert!(
574 sb.y >= -0.5 && sb.y + sb.height <= 600.5,
575 "[{structure}] surface vertically off-screen: {sb:?}"
576 );
577 assert!(
578 sb.x >= -0.5 && sb.x + sb.width <= 900.5,
579 "[{structure}] surface horizontally off-screen: {sb:?}"
580 );
581 // bottom-trailing anchor: lower-right quadrant.
582 assert!(
583 sb.y + sb.height > 400.0,
584 "[{structure}] surface not near bottom: {sb:?}"
585 );
586 assert!(
587 sb.x + sb.width > 500.0,
588 "[{structure}] surface not near right edge: {sb:?}"
589 );
590 }
591 }
592
593 /// Investigation: a flexless VStack root fills the window (bounds
594 /// 900x600) but top-clusters its children, leaving the slack at the
595 /// bottom; inserting an `Expand::vertical` between body and status
596 /// pins the status bar to the bottom edge. Documents the layout
597 /// contract so a future reader doesn't mistake the top-clustering
598 /// for a window bug.
599 #[test]
600 fn flexless_root_top_clusters_expand_pins_to_bottom() {
601 use crate::primitives::Spacer;
602 let build = |with_expand: bool| {
603 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
604 let toolbar = tree.add(
605 FixedSize::new()
606 .width(900.0)
607 .height(40.0)
608 .child(Spacer::new()),
609 );
610 let status = tree.add(
611 FixedSize::new()
612 .width(900.0)
613 .height(30.0)
614 .child(Spacer::new()),
615 );
616 let mut vstack = VStack::new().spacing(0.0).add_child(toolbar);
617 if with_expand {
618 let body = tree.add(
619 FixedSize::new()
620 .width(900.0)
621 .height(100.0)
622 .child(Spacer::new()),
623 );
624 let filled = tree.add(Expand::vertical().respect_intrinsic().child_id(body));
625 vstack = vstack.add_child(filled);
626 } else {
627 let body = tree.add(
628 FixedSize::new()
629 .width(900.0)
630 .height(100.0)
631 .child(Spacer::new()),
632 );
633 vstack = vstack.add_child(body);
634 }
635 vstack = vstack.add_child(status);
636 let root = tree.add(vstack);
637 tree.layout(SizeProposal::exact(900.0, 600.0));
638 (tree.bounds(root), tree.bounds(status))
639 };
640
641 let (root_plain, status_plain) = build(false);
642 assert!((root_plain.height - 600.0).abs() < 0.5, "root fills window");
643 assert!(
644 (status_plain.y - 140.0).abs() < 0.5,
645 "flexless: status top-clusters at 140"
646 );
647
648 let (root_exp, status_exp) = build(true);
649 assert!(
650 (root_exp.height - 600.0).abs() < 0.5,
651 "root still fills window"
652 );
653 assert!(
654 (status_exp.y + status_exp.height - 600.0).abs() < 0.5,
655 "with Expand::vertical the status bar pins to the bottom edge, got {status_exp:?}"
656 );
657 }
658
659 /// The host is built at startup with an EMPTY registry (the common
660 /// case: the main window). A toast enqueued LATER (via
661 /// `show_toast`) must drive a rebuild so the surface appears on the
662 /// next layout pass. This reproduces the real-app path that the
663 /// first test (toast present at build time) does not exercise.
664 #[test]
665 fn host_shows_toast_enqueued_after_initial_layout() {
666 let o = opts();
667 let registry = ToastRegistry::new(o.clone());
668
669 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
670 let user_root = tree.add(small_root());
671 let host_id = tree.add(ToastHost::new(registry.clone(), o));
672 let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
673 tree.add(ZStack::new().add_child(filled).add_child(host_id));
674
675 tree.layout(SizeProposal::exact(900.0, 600.0));
676 assert_eq!(
677 tree.children(host_id).len(),
678 0,
679 "no toast should be present before any enqueue"
680 );
681
682 // Equivalent to `ctx.show_toast(...)` after startup.
683 registry.enqueue(Toast::info(LocalizedString::literal("Later")));
684
685 // A subsequent layout pass (next frame) must rebuild the host
686 // and materialise the surface.
687 tree.layout(SizeProposal::exact(900.0, 600.0));
688 assert_eq!(
689 tree.children(host_id).len(),
690 1,
691 "toast enqueued after initial layout did not appear — host did not rebuild"
692 );
693 }
694
695 /// Full auto-dismiss lifecycle in a live `WidgetTree`: a timed toast
696 /// appears, the host arms its per-frame timer (gate true), then the
697 /// timer expires, the surface is removed, and the gate goes false so
698 /// the event loop can sleep again. This is the headless analogue of
699 /// the real-window CPU time-series: pump while a toast is alive, idle
700 /// once it auto-dismisses.
701 #[test]
702 fn timed_toast_auto_dismisses_and_releases_the_frame_loop() {
703 use std::time::Duration;
704
705 let o = opts();
706 let registry = ToastRegistry::new(o.clone());
707
708 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
709 let user_root = tree.add(small_root());
710 let host_id = tree.add(ToastHost::new(registry.clone(), o));
711 let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
712 tree.add(ZStack::new().add_child(filled).add_child(host_id));
713
714 tree.layout(SizeProposal::exact(900.0, 600.0));
715 assert_eq!(tree.children(host_id).len(), 0);
716 assert!(
717 !registry.has_running_timers(),
718 "empty host must not keep the frame loop awake"
719 );
720
721 // Show a toast with a finite auto-dismiss timer.
722 registry.enqueue(
723 Toast::info(LocalizedString::literal("Saved"))
724 .auto_dismiss_after(Duration::from_millis(500)),
725 );
726 tree.layout(SizeProposal::exact(900.0, 600.0));
727 assert_eq!(tree.children(host_id).len(), 1, "surface should appear");
728 assert!(
729 registry.has_running_timers(),
730 "a live timed toast must arm the frame loop"
731 );
732
733 // Frames elapse past the timeout (the host's frame-tick effect
734 // calls this with wall-clock dt; we drive it directly).
735 let expired = registry.tick_timers(Duration::from_millis(600), false);
736 assert!(expired, "the toast should expire after its timeout");
737
738 // Next layout pass rebuilds the host: surface gone, loop idle.
739 tree.layout(SizeProposal::exact(900.0, 600.0));
740 assert_eq!(
741 tree.children(host_id).len(),
742 0,
743 "expired toast surface should be torn down"
744 );
745 assert!(
746 !registry.has_running_timers(),
747 "after the last timer expires the host must release the frame loop"
748 );
749 }
750
751 /// A visible timed toast must arm a one-shot `wake_at` deadline so
752 /// the event loop sleeps until expiry — not pin a 60 fps poll. An
753 /// empty host, or one holding only sticky toasts, arms no deadline.
754 #[test]
755 fn timed_toast_schedules_a_deadline_not_a_poll() {
756 use std::time::{Duration, Instant};
757
758 let o = opts();
759 let registry = ToastRegistry::new(o.clone());
760
761 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
762 let user_root = tree.add(small_root());
763 let host_id = tree.add(ToastHost::new(registry.clone(), o));
764 let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
765 tree.add(ZStack::new().add_child(filled).add_child(host_id));
766
767 let wake = tree.wake_at_handle();
768
769 // Empty host: nothing to wait for.
770 tree.layout(SizeProposal::exact(900.0, 600.0));
771 assert!(
772 wake.get().is_none(),
773 "empty host must not arm a wake deadline"
774 );
775
776 // Sticky-only: a persistent toast has no timer → still no deadline.
777 registry.enqueue(Toast::error(LocalizedString::literal("sticky")).persistent());
778 tree.layout(SizeProposal::exact(900.0, 600.0));
779 assert!(
780 wake.get().is_none(),
781 "a sticky toast has no timer, so no deadline is armed"
782 );
783
784 // Timed toast: a future deadline appears (not an immediate wake).
785 let before = Instant::now();
786 registry.enqueue(
787 Toast::info(LocalizedString::literal("timed"))
788 .auto_dismiss_after(Duration::from_secs(5)),
789 );
790 tree.layout(SizeProposal::exact(900.0, 600.0));
791 let deadline = wake.get();
792 assert!(deadline.is_some(), "timed toast must arm a wake deadline");
793 assert!(
794 deadline.unwrap() > before,
795 "deadline must be in the future, not an immediate busy-wake"
796 );
797 }
798
799 // -----------------------------------------------------------------
800 // Multi-window delivery — two REAL `ToastHost`s, two REAL
801 // `WidgetTree`s sharing one registry, exactly the shape
802 // `WindowManager` uses for a real multi-window app. Every test
803 // above builds at most one host/tree, so none of them can catch
804 // either half of the routing contract: (a) that a host actually
805 // EXCLUDES an entry that isn't routed to it, and (b) that delivery
806 // doesn't depend on which window's `WidgetTree` happens to
807 // reconcile first — the single signal shared by every window's
808 // host used to silently starve whichever window reconciled
809 // second, see `ToastRegistry::version_signal`.
810 // -----------------------------------------------------------------
811
812 /// Two independent windows (ids 1 and 2), each with its own
813 /// `WidgetTree` + `ToastHost`, sharing ONE `ToastRegistry` via a
814 /// shared `app_state` — mirrors `WindowManager`'s real shape (one
815 /// tree per open window, one registry singleton). Each window also
816 /// gets a "Save" button wired to `ctx.show_toast(...)` so a test
817 /// can exercise the origin-window-default path from inside that
818 /// SPECIFIC window's real `EventContext`, the way
819 /// `show_toast_default_targets_the_originating_window` does for a
820 /// single window.
821 fn two_window_hosts() -> (
822 WidgetTree,
823 WidgetId,
824 WidgetId,
825 WidgetTree,
826 WidgetId,
827 WidgetId,
828 ToastRegistry,
829 ) {
830 two_window_hosts_with_opts(opts())
831 }
832
833 /// Same shape as [`two_window_hosts`] but with caller-supplied
834 /// `ToastInstallOptions` — used by the slot-pool admission test
835 /// below, which needs a small `max_visible` to force overflow.
836 fn two_window_hosts_with_opts(
837 o: ToastInstallOptions,
838 ) -> (
839 WidgetTree,
840 WidgetId,
841 WidgetId,
842 WidgetTree,
843 WidgetId,
844 WidgetId,
845 ToastRegistry,
846 ) {
847 use crate::button::Button;
848 use std::any::{Any, TypeId};
849 use std::collections::HashMap;
850 use teksilo_core::window::state::WindowStateInit;
851 use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
852 use teksilo_i18n::lit;
853
854 let registry = ToastRegistry::new(o.clone());
855
856 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
857 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
858 let app_context =
859 Rc::new(teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state));
860
861 let build_window = |window_id: u64| {
862 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
863 tree.set_app_context(app_context.clone());
864 tree.set_window_state(WindowState::new(WindowStateInit {
865 id: TeksiloWindowId::new(window_id),
866 string_id: Some(format!("w{window_id}")),
867 placement: WindowPlacement::Floating,
868 title: "Test".to_string(),
869 size: (800, 600),
870 position: (0, 0),
871 focused: false,
872 resizable: true,
873 always_on_top: false,
874 }));
875 let btn = tree.add(Button::new(lit!("Save")).on_activate_fn(|ctx| {
876 let _ = Toast::info(lit!("Saved")).present(ctx);
877 }));
878 let host_id = tree.add(ToastHost::new(registry.clone(), o.clone()));
879 let filled = tree.add(Expand::new().respect_intrinsic().child_id(btn));
880 tree.add(ZStack::new().add_child(filled).add_child(host_id));
881 tree.layout(SizeProposal::exact(900.0, 600.0));
882 (tree, btn, host_id)
883 };
884
885 let (tree1, btn1, host1) = build_window(1);
886 let (tree2, btn2, host2) = build_window(2);
887 (tree1, btn1, host1, tree2, btn2, host2, registry)
888 }
889
890 /// `.broadcast()` must reach every open window's host — and must
891 /// keep doing so regardless of which window's `WidgetTree`
892 /// reconciles first, exactly like `WindowManager::
893 /// request_redraw_needing_render` sweeping windows in whatever
894 /// order its internal `HashMap` iterates them.
895 #[test]
896 fn broadcast_toast_reaches_every_host_regardless_of_reconcile_order() {
897 let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
898 assert_eq!(tree1.children(host1).len(), 0);
899 assert_eq!(tree2.children(host2).len(), 0);
900
901 registry.enqueue(Toast::warning(LocalizedString::literal("everyone")).broadcast());
902
903 // Window 1 reconciles first (the "lucky" order) — must still
904 // see the broadcast.
905 tree1.layout(SizeProposal::exact(900.0, 600.0));
906 assert_eq!(
907 tree1.children(host1).len(),
908 1,
909 "window 1's host must render the broadcast toast"
910 );
911 // Window 2 reconciles SECOND — this is exactly the case that
912 // silently missed the toast before per-window signals: the
913 // shared flag was already cleared by window 1's flush above.
914 tree2.layout(SizeProposal::exact(900.0, 600.0));
915 assert_eq!(
916 tree2.children(host2).len(),
917 1,
918 "window 2's host must ALSO render the broadcast toast, even though its \
919 WidgetTree reconciled second"
920 );
921 }
922
923 /// Same broadcast scenario with the reconcile order flipped, to
924 /// prove delivery genuinely doesn't depend on iteration order (not
925 /// just that the specific order exercised above happens to work).
926 #[test]
927 fn broadcast_toast_reaches_every_host_in_the_reverse_reconcile_order_too() {
928 let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
929
930 registry.enqueue(Toast::warning(LocalizedString::literal("everyone")).broadcast());
931
932 tree2.layout(SizeProposal::exact(900.0, 600.0));
933 assert_eq!(
934 tree2.children(host2).len(),
935 1,
936 "window 2's host must render the broadcast toast when it reconciles first"
937 );
938 tree1.layout(SizeProposal::exact(900.0, 600.0));
939 assert_eq!(
940 tree1.children(host1).len(),
941 1,
942 "window 1's host must ALSO render it, even reconciling second"
943 );
944 }
945
946 /// A toast presented with no explicit target from inside window
947 /// 1's real `EventContext` must render ONLY in window 1's host —
948 /// never in window 2's, even when window 2 (the non-recipient)
949 /// happens to reconcile FIRST.
950 #[test]
951 fn origin_window_default_toast_reaches_only_the_presenting_hosts_window() {
952 let (mut tree1, btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
953
954 tree1.click(btn1);
955 assert_eq!(
956 registry.live_count(),
957 1,
958 "the click must have enqueued a toast"
959 );
960
961 // Non-recipient window reconciles first.
962 tree2.layout(SizeProposal::exact(900.0, 600.0));
963 assert_eq!(
964 tree2.children(host2).len(),
965 0,
966 "window 2 never routed to must render nothing, regardless of reconcile order"
967 );
968 // The actual target reconciles second — must still get it.
969 tree1.layout(SizeProposal::exact(900.0, 600.0));
970 assert_eq!(
971 tree1.children(host1).len(),
972 1,
973 "window 1, the presenting window, must render its own toast"
974 );
975 }
976
977 /// `.target(audience)` must reach only hosts whose window is
978 /// currently assigned that audience — never a host with no
979 /// audience or a different one — regardless of reconcile order.
980 #[test]
981 fn audience_targeted_toast_reaches_only_matching_hosts_regardless_of_reconcile_order() {
982 use teksilo_core::window::TeksiloWindowId;
983
984 let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
985 let audience = ToastAudience::new(7);
986 registry.set_window_audience(TeksiloWindowId::new(1), Some(audience));
987 // Window 2 is deliberately left unassigned (`None`).
988
989 // Absorb the audience assignment's own rebuild (driven by the
990 // genuinely-per-window `window_audience_signal`, unrelated to
991 // the toast rebuild signal under test) BEFORE enqueuing the
992 // toast below — otherwise window 1's rebuild in the
993 // assertions could be (mis)attributed to this prior mutation
994 // instead of to the toast's own routing signal, masking a
995 // regression in the latter.
996 tree1.layout(SizeProposal::exact(900.0, 600.0));
997 tree2.layout(SizeProposal::exact(900.0, 600.0));
998 assert_eq!(tree1.children(host1).len(), 0);
999 assert_eq!(tree2.children(host2).len(), 0);
1000
1001 registry.enqueue(Toast::info(LocalizedString::literal("scoped")).target(audience));
1002
1003 // The non-matching window reconciles first.
1004 tree2.layout(SizeProposal::exact(900.0, 600.0));
1005 assert_eq!(
1006 tree2.children(host2).len(),
1007 0,
1008 "window 2 has no matching audience and must render nothing"
1009 );
1010 // The matching window reconciles second — must still get it.
1011 tree1.layout(SizeProposal::exact(900.0, 600.0));
1012 assert_eq!(
1013 tree1.children(host1).len(),
1014 1,
1015 "window 1, assigned the matching audience, must render the toast"
1016 );
1017 }
1018
1019 /// The audience assignment a `ToastHost` reads is a live `Signal`,
1020 /// not a value captured once when the host is first built.
1021 /// Skribisto retargets a window's audience mid-session (an
1022 /// in-place Work switch keeps the same window/host alive but
1023 /// changes which document it's showing) — so a toast that was
1024 /// already live and targeted at the audience a window is
1025 /// RETARGETED TO must start appearing there on the very next
1026 /// layout, with no need to tear down and rebuild the host. If
1027 /// `window_audience_signal` were only consulted once (e.g. to seed
1028 /// `initial_audience`) instead of bound at `BindingLevel::Rebuild`
1029 /// on every build, this reassignment would never reach the host
1030 /// and the final assertion below would fail while every assertion
1031 /// before it still passed — that's what makes this genuinely a
1032 /// different guarantee from `audience_targeted_toast_reaches_only_matching_hosts_regardless_of_reconcile_order`
1033 /// above, not a duplicate of it.
1034 #[test]
1035 fn audience_reassignment_is_observed_live_not_just_at_host_construction() {
1036 use teksilo_core::window::TeksiloWindowId;
1037
1038 let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
1039 let audience_a = ToastAudience::new(1);
1040 let audience_b = ToastAudience::new(2);
1041 registry.set_window_audience(TeksiloWindowId::new(1), Some(audience_a));
1042 registry.set_window_audience(TeksiloWindowId::new(2), Some(audience_b));
1043 // Absorb the audience-assignment rebuilds before enqueuing, for
1044 // the same reason as the test above.
1045 tree1.layout(SizeProposal::exact(900.0, 600.0));
1046 tree2.layout(SizeProposal::exact(900.0, 600.0));
1047
1048 registry.enqueue(Toast::info(LocalizedString::literal("for a")).target(audience_a));
1049 tree1.layout(SizeProposal::exact(900.0, 600.0));
1050 tree2.layout(SizeProposal::exact(900.0, 600.0));
1051 assert_eq!(
1052 tree1.children(host1).len(),
1053 1,
1054 "window 1 (assigned audience A) renders the toast"
1055 );
1056 assert_eq!(
1057 tree2.children(host2).len(),
1058 0,
1059 "window 2 (assigned audience B) must not render an A-targeted toast"
1060 );
1061
1062 // Retarget window 2 to audience A. The toast above is still
1063 // the SAME live entry — it is never re-enqueued.
1064 registry.set_window_audience(TeksiloWindowId::new(2), Some(audience_a));
1065 tree2.layout(SizeProposal::exact(900.0, 600.0));
1066 assert_eq!(
1067 tree2.children(host2).len(),
1068 1,
1069 "after reassigning window 2 to audience A, the still-live toast must now \
1070 render there too — proves the audience signal is live, not read once"
1071 );
1072 // Window 1 must be entirely unaffected by window 2's reassignment.
1073 tree1.layout(SizeProposal::exact(900.0, 600.0));
1074 assert_eq!(tree1.children(host1).len(), 1);
1075 }
1076
1077 /// Render-side counterpart of `per_audience_admission_one_burst_does_not_starve_another_audience`
1078 /// in `toast.rs`, which proves the guarantee only at the registry
1079 /// bookkeeping level (`live_count` / `is_alive`). The bug this
1080 /// guards against is specifically a RENDER-side one: a host that
1081 /// (re)computes its own slot cap by counting entries across every
1082 /// route instead of just its own bucket would under-render
1083 /// audience B even though the registry's admission was correct —
1084 /// so the assertion here has to be on `tree.children(host)`, not
1085 /// on registry state.
1086 #[test]
1087 fn per_audience_burst_does_not_starve_another_audiences_rendered_slot() {
1088 use teksilo_core::window::TeksiloWindowId;
1089
1090 let o = ToastInstallOptions {
1091 archive: None,
1092 max_visible: 2,
1093 ..ToastInstallOptions::default()
1094 };
1095 let (mut tree_a, _btn_a, host_a, mut tree_b, _btn_b, host_b, registry) =
1096 two_window_hosts_with_opts(o);
1097 let audience_a = ToastAudience::new(1);
1098 let audience_b = ToastAudience::new(2);
1099 registry.set_window_audience(TeksiloWindowId::new(1), Some(audience_a));
1100 registry.set_window_audience(TeksiloWindowId::new(2), Some(audience_b));
1101 tree_a.layout(SizeProposal::exact(900.0, 600.0));
1102 tree_b.layout(SizeProposal::exact(900.0, 600.0));
1103
1104 // Flood audience A's bucket past its cap of 2.
1105 registry.enqueue(Toast::info(LocalizedString::literal("a1")).target(audience_a));
1106 registry.enqueue(Toast::info(LocalizedString::literal("a2")).target(audience_a));
1107 let (a3, _) =
1108 registry.enqueue(Toast::info(LocalizedString::literal("a3")).target(audience_a));
1109 assert!(
1110 !a3.is_alive(),
1111 "audience A's third toast overflows its own bucket (sanity check on the burst)"
1112 );
1113
1114 // Audience B's own toast, presented AFTER A's burst — its own
1115 // bucket is untouched by A's overflow.
1116 registry.enqueue(Toast::info(LocalizedString::literal("b1")).target(audience_b));
1117
1118 tree_a.layout(SizeProposal::exact(900.0, 600.0));
1119 tree_b.layout(SizeProposal::exact(900.0, 600.0));
1120
1121 assert_eq!(
1122 tree_a.children(host_a).len(),
1123 2,
1124 "audience A's host renders exactly its own capped bucket (2), not the overflowed 3rd"
1125 );
1126 assert_eq!(
1127 tree_b.children(host_b).len(),
1128 1,
1129 "audience B's toast is still admitted AND RENDERED in B's own host — A's \
1130 burst must not starve B's rendered slot"
1131 );
1132 }
1133}