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