Skip to main content

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                        &registry_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}