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