use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::{Duration, Instant};
use teksilo_canvas::{Rect, SizeProposal, Vec2};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::Corner;
use crate::notification::NotificationArchive;
use crate::toast::registry::ToastRegistry;
use crate::toast::surface::{ToastSurface, ToastSurfaceData};
use crate::toast::{ToastAudience, ToastRoute};
#[derive(Clone, Debug)]
pub struct ToastInstallOptions {
pub corner: Corner,
pub margin: Vec2,
pub gap: f32,
pub max_visible: usize,
pub entry_width: f32,
pub pause_on_hover_group: bool,
pub archive: Option<NotificationArchive>,
pub initial_audience: Option<ToastAudience>,
}
impl Default for ToastInstallOptions {
fn default() -> Self {
Self {
corner: Corner::BottomTrailing,
margin: Vec2::new(24.0, 24.0),
gap: 8.0,
max_visible: 5,
entry_width: 380.0,
pause_on_hover_group: true,
archive: Some(NotificationArchive::persistent(
crate::notification::ARCHIVE_FILE_NAME,
)),
initial_audience: None,
}
}
}
pub struct ToastHost {
registry: ToastRegistry,
options: ToastInstallOptions,
toast_surface_ids: Vec<WidgetId>,
last_tick_at: Rc<RefCell<Option<Instant>>>,
has_pending_drain_handler: Cell<bool>,
initial_audience_applied: Cell<bool>,
}
impl ToastHost {
pub fn new(registry: ToastRegistry, options: ToastInstallOptions) -> Self {
Self {
registry,
options,
toast_surface_ids: Vec::new(),
last_tick_at: Rc::new(RefCell::new(None)),
has_pending_drain_handler: Cell::new(false),
initial_audience_applied: Cell::new(false),
}
}
pub fn wrapping(
_user_root: WidgetId,
registry: ToastRegistry,
options: ToastInstallOptions,
) -> Self {
Self::new(registry, options)
}
}
impl std::fmt::Debug for ToastHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToastHost")
.field("toast_count", &self.toast_surface_ids.len())
.field("options", &self.options)
.finish()
}
}
const HOVER_POLL_INTERVAL: Duration = Duration::from_millis(120);
const MIN_WAKE_DELAY: Duration = Duration::from_millis(8);
fn schedule_toast_wake(
registry: &ToastRegistry,
wake_at: &Rc<Cell<Option<Instant>>>,
pause_on_hover_group: bool,
now: Instant,
) {
let paused = pause_on_hover_group && registry.hover_count_signal().get() > 0;
let delay = if paused {
HOVER_POLL_INTERVAL
} else {
match registry.min_running_timer() {
Some(remaining) => remaining.max(MIN_WAKE_DELAY),
None => return, }
};
let target = now + delay;
let merged = match wake_at.get() {
Some(existing) if existing <= target => existing,
_ => target,
};
wake_at.set(Some(merged));
}
impl Widget for ToastHost {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let my_window = ctx.window().map(|w| w.id());
self.registry.version_signal().bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::Rebuild,
);
let my_audience_signal = my_window.map(|w| self.registry.window_audience_signal(w));
if let Some(sig) = &my_audience_signal {
if !self.initial_audience_applied.get() {
if let Some(initial) = self.options.initial_audience {
sig.set(Some(initial));
}
self.initial_audience_applied.set(true);
}
sig.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
}
let my_audience: Option<ToastAudience> = my_audience_signal.and_then(|s| s.get());
let entry_ids = self.registry.live_entry_ids();
let mut surface_ids = Vec::with_capacity(entry_ids.len());
for entry_id in &entry_ids {
let route_matches = self
.registry
.with_entry(*entry_id, |e| match e.route {
ToastRoute::Broadcast => true,
ToastRoute::Window(w) => my_window == Some(w),
ToastRoute::Audience(a) => my_audience == Some(a),
})
.unwrap_or(false);
if !route_matches {
continue;
}
let Some(data) = self.registry.with_entry(*entry_id, |e| ToastSurfaceData {
entry_id: e.entry_id,
severity: e.severity,
priority: e.priority,
title: e.title.clone(),
body: e.body.clone(),
announcement: e.announcement.clone(),
actions: e.actions.clone(),
show_close_button: e.show_close_button,
on_click: e.on_click.clone(),
style_override: e.style_override.clone(),
body_state: e.body_state.clone(),
}) else {
continue;
};
let leading = self.registry.take_leading(*entry_id);
let closable_on_escape = self
.registry
.with_entry(*entry_id, |e| e.closable_on_escape)
.unwrap_or(true);
let surface =
ToastSurface::new(data, leading, self.registry.clone(), closable_on_escape);
surface_ids.push(ctx.add(surface));
}
if self.registry.has_running_timers() {
let registry_for_tick = self.registry.clone();
let last_tick_at = self.last_tick_at.clone();
let wake_at = ctx.wake_at_handle();
let pause_on_hover_group = self.options.pause_on_hover_group;
if last_tick_at.borrow().is_none() {
*last_tick_at.borrow_mut() = Some(Instant::now());
}
let wake_for_tick = wake_at.clone();
ctx.effect(&ctx.frame_tick(), move |_delta_from_signal| {
let now = Instant::now();
let dt = {
let mut last = last_tick_at.borrow_mut();
let result = last
.map(|t| now.saturating_duration_since(t))
.unwrap_or_default();
*last = Some(now);
result
};
let paused =
pause_on_hover_group && registry_for_tick.hover_count_signal().get() > 0;
registry_for_tick.tick_timers(dt, paused);
if registry_for_tick.has_running_timers() {
schedule_toast_wake(
®istry_for_tick,
&wake_for_tick,
pause_on_hover_group,
now,
);
}
});
schedule_toast_wake(
&self.registry,
&wake_at,
pause_on_hover_group,
Instant::now(),
);
} else {
*self.last_tick_at.borrow_mut() = None;
}
if !self.has_pending_drain_handler.get() {
let registry_for_drain = self.registry.clone();
let handlers =
HandlerSet::new()
.event_pass_through(true)
.on_pointer_event(move |_event, ctx| {
registry_for_drain.drain_pending_dismiss_callbacks(ctx);
teksilo_core::event::EventResponse::Ignored
});
ctx.apply_self_handlers(handlers);
self.has_pending_drain_handler.set(true);
}
self.toast_surface_ids = surface_ids.clone();
surface_ids
}
fn layout_response(
&self,
proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
proposal
.resolve(
proposal.width.unwrap_or(0.0),
proposal.height.unwrap_or(0.0),
)
.into()
}
fn place_children(
&self,
bounds: Rect,
proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
if children.is_empty() {
return;
}
let rtl = ctx.is_rtl();
let vw = proposal.width.unwrap_or(bounds.width);
let vh = proposal.height.unwrap_or(bounds.height);
let mut surface_sizes = Vec::with_capacity(children.len());
for placement in children.iter() {
let resp = ctx
.child_size(
placement.id,
SizeProposal {
width: Some(self.options.entry_width),
height: None,
},
)
.unwrap_or_else(|| teksilo_canvas::Size::new(self.options.entry_width, 0.0));
surface_sizes.push(teksilo_canvas::Size::new(
self.options.entry_width,
resp.height,
));
}
let len = children.len();
for i in 0..len {
let size = surface_sizes[i];
let mut stack_offset = self.options.margin.y;
for j in (i + 1)..len {
stack_offset += surface_sizes[j].height + self.options.gap;
}
let (x, y) = self.options.corner.resolve(
(size.width, size.height),
(vw, vh),
(self.options.margin.x, stack_offset),
rtl,
);
children[i].origin = teksilo_canvas::Point::new(x, y);
children[i].size = size;
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
builder.set_hidden();
}
fn children(&self) -> Vec<WidgetId> {
self.toast_surface_ids.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::{Expand, FixedSize, VStack, ZStack};
use crate::toast::Toast;
use crate::toast::registry::ToastRegistry;
use teksilo_canvas::SizeProposal;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::LocalizedString;
fn opts() -> ToastInstallOptions {
ToastInstallOptions {
archive: None,
..ToastInstallOptions::default()
}
}
fn small_root() -> impl Widget {
VStack::new().child(
FixedSize::new()
.width(200.0)
.height(120.0)
.child(crate::primitives::Spacer::new()),
)
}
fn surface_bounds(structure: &str) -> (Rect, Rect) {
let o = opts();
let registry = ToastRegistry::new(o.clone());
registry.enqueue(Toast::info(LocalizedString::literal("Hello")));
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let user_root = tree.add(small_root());
let host_id = tree.add(ToastHost::new(registry.clone(), o));
match structure {
"bare" => {
tree.add(ZStack::new().add_child(user_root).add_child(host_id));
}
"expand" => {
let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
tree.add(ZStack::new().add_child(filled).add_child(host_id));
}
_ => unreachable!(),
}
tree.layout(SizeProposal::exact(900.0, 600.0));
let host_bounds = tree.bounds(host_id);
let surfaces = tree.children(host_id);
assert_eq!(
surfaces.len(),
1,
"[{structure}] expected one toast surface"
);
(host_bounds, tree.bounds(surfaces[0]))
}
#[test]
fn toast_surface_is_visible_at_bottom_right_with_and_without_expand() {
for structure in ["bare", "expand"] {
let (host_bounds, sb) = surface_bounds(structure);
assert!(
(host_bounds.width - 900.0).abs() < 0.5 && (host_bounds.height - 600.0).abs() < 0.5,
"[{structure}] host should fill window, got {host_bounds:?}"
);
assert!(sb.height > 1.0, "[{structure}] surface collapsed: {sb:?}");
assert!(
sb.y >= -0.5 && sb.y + sb.height <= 600.5,
"[{structure}] surface vertically off-screen: {sb:?}"
);
assert!(
sb.x >= -0.5 && sb.x + sb.width <= 900.5,
"[{structure}] surface horizontally off-screen: {sb:?}"
);
assert!(
sb.y + sb.height > 400.0,
"[{structure}] surface not near bottom: {sb:?}"
);
assert!(
sb.x + sb.width > 500.0,
"[{structure}] surface not near right edge: {sb:?}"
);
}
}
#[test]
fn flexless_root_top_clusters_expand_pins_to_bottom() {
use crate::primitives::Spacer;
let build = |with_expand: bool| {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let toolbar = tree.add(
FixedSize::new()
.width(900.0)
.height(40.0)
.child(Spacer::new()),
);
let status = tree.add(
FixedSize::new()
.width(900.0)
.height(30.0)
.child(Spacer::new()),
);
let mut vstack = VStack::new().spacing(0.0).add_child(toolbar);
if with_expand {
let body = tree.add(
FixedSize::new()
.width(900.0)
.height(100.0)
.child(Spacer::new()),
);
let filled = tree.add(Expand::vertical().respect_intrinsic().child_id(body));
vstack = vstack.add_child(filled);
} else {
let body = tree.add(
FixedSize::new()
.width(900.0)
.height(100.0)
.child(Spacer::new()),
);
vstack = vstack.add_child(body);
}
vstack = vstack.add_child(status);
let root = tree.add(vstack);
tree.layout(SizeProposal::exact(900.0, 600.0));
(tree.bounds(root), tree.bounds(status))
};
let (root_plain, status_plain) = build(false);
assert!((root_plain.height - 600.0).abs() < 0.5, "root fills window");
assert!(
(status_plain.y - 140.0).abs() < 0.5,
"flexless: status top-clusters at 140"
);
let (root_exp, status_exp) = build(true);
assert!(
(root_exp.height - 600.0).abs() < 0.5,
"root still fills window"
);
assert!(
(status_exp.y + status_exp.height - 600.0).abs() < 0.5,
"with Expand::vertical the status bar pins to the bottom edge, got {status_exp:?}"
);
}
#[test]
fn host_shows_toast_enqueued_after_initial_layout() {
let o = opts();
let registry = ToastRegistry::new(o.clone());
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let user_root = tree.add(small_root());
let host_id = tree.add(ToastHost::new(registry.clone(), o));
let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
tree.add(ZStack::new().add_child(filled).add_child(host_id));
tree.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree.children(host_id).len(),
0,
"no toast should be present before any enqueue"
);
registry.enqueue(Toast::info(LocalizedString::literal("Later")));
tree.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree.children(host_id).len(),
1,
"toast enqueued after initial layout did not appear — host did not rebuild"
);
}
#[test]
fn timed_toast_auto_dismisses_and_releases_the_frame_loop() {
use std::time::Duration;
let o = opts();
let registry = ToastRegistry::new(o.clone());
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let user_root = tree.add(small_root());
let host_id = tree.add(ToastHost::new(registry.clone(), o));
let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
tree.add(ZStack::new().add_child(filled).add_child(host_id));
tree.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(tree.children(host_id).len(), 0);
assert!(
!registry.has_running_timers(),
"empty host must not keep the frame loop awake"
);
registry.enqueue(
Toast::info(LocalizedString::literal("Saved"))
.auto_dismiss_after(Duration::from_millis(500)),
);
tree.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(tree.children(host_id).len(), 1, "surface should appear");
assert!(
registry.has_running_timers(),
"a live timed toast must arm the frame loop"
);
let expired = registry.tick_timers(Duration::from_millis(600), false);
assert!(expired, "the toast should expire after its timeout");
tree.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree.children(host_id).len(),
0,
"expired toast surface should be torn down"
);
assert!(
!registry.has_running_timers(),
"after the last timer expires the host must release the frame loop"
);
}
#[test]
fn timed_toast_schedules_a_deadline_not_a_poll() {
use std::time::{Duration, Instant};
let o = opts();
let registry = ToastRegistry::new(o.clone());
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let user_root = tree.add(small_root());
let host_id = tree.add(ToastHost::new(registry.clone(), o));
let filled = tree.add(Expand::new().respect_intrinsic().child_id(user_root));
tree.add(ZStack::new().add_child(filled).add_child(host_id));
let wake = tree.wake_at_handle();
tree.layout(SizeProposal::exact(900.0, 600.0));
assert!(
wake.get().is_none(),
"empty host must not arm a wake deadline"
);
registry.enqueue(Toast::error(LocalizedString::literal("sticky")).persistent());
tree.layout(SizeProposal::exact(900.0, 600.0));
assert!(
wake.get().is_none(),
"a sticky toast has no timer, so no deadline is armed"
);
let before = Instant::now();
registry.enqueue(
Toast::info(LocalizedString::literal("timed"))
.auto_dismiss_after(Duration::from_secs(5)),
);
tree.layout(SizeProposal::exact(900.0, 600.0));
let deadline = wake.get();
assert!(deadline.is_some(), "timed toast must arm a wake deadline");
assert!(
deadline.unwrap() > before,
"deadline must be in the future, not an immediate busy-wake"
);
}
fn two_window_hosts() -> (
WidgetTree,
WidgetId,
WidgetId,
WidgetTree,
WidgetId,
WidgetId,
ToastRegistry,
) {
two_window_hosts_with_opts(opts())
}
fn two_window_hosts_with_opts(
o: ToastInstallOptions,
) -> (
WidgetTree,
WidgetId,
WidgetId,
WidgetTree,
WidgetId,
WidgetId,
ToastRegistry,
) {
use crate::button::Button;
use std::any::{Any, TypeId};
use std::collections::HashMap;
use teksilo_core::window::state::WindowStateInit;
use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
use teksilo_i18n::lit;
let registry = ToastRegistry::new(o.clone());
let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
let app_context =
Rc::new(teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state));
let build_window = |window_id: u64| {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_app_context(app_context.clone());
tree.set_window_state(WindowState::new(WindowStateInit {
id: TeksiloWindowId::new(window_id),
string_id: Some(format!("w{window_id}")),
placement: WindowPlacement::Floating,
title: "Test".to_string(),
size: (800, 600),
position: (0, 0),
focused: false,
resizable: true,
always_on_top: false,
}));
let btn = tree.add(Button::new(lit!("Save")).on_activate_fn(|ctx| {
let _ = Toast::info(lit!("Saved")).present(ctx);
}));
let host_id = tree.add(ToastHost::new(registry.clone(), o.clone()));
let filled = tree.add(Expand::new().respect_intrinsic().child_id(btn));
tree.add(ZStack::new().add_child(filled).add_child(host_id));
tree.layout(SizeProposal::exact(900.0, 600.0));
(tree, btn, host_id)
};
let (tree1, btn1, host1) = build_window(1);
let (tree2, btn2, host2) = build_window(2);
(tree1, btn1, host1, tree2, btn2, host2, registry)
}
#[test]
fn broadcast_toast_reaches_every_host_regardless_of_reconcile_order() {
let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
assert_eq!(tree1.children(host1).len(), 0);
assert_eq!(tree2.children(host2).len(), 0);
registry.enqueue(Toast::warning(LocalizedString::literal("everyone")).broadcast());
tree1.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree1.children(host1).len(),
1,
"window 1's host must render the broadcast toast"
);
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree2.children(host2).len(),
1,
"window 2's host must ALSO render the broadcast toast, even though its \
WidgetTree reconciled second"
);
}
#[test]
fn broadcast_toast_reaches_every_host_in_the_reverse_reconcile_order_too() {
let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
registry.enqueue(Toast::warning(LocalizedString::literal("everyone")).broadcast());
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree2.children(host2).len(),
1,
"window 2's host must render the broadcast toast when it reconciles first"
);
tree1.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree1.children(host1).len(),
1,
"window 1's host must ALSO render it, even reconciling second"
);
}
#[test]
fn origin_window_default_toast_reaches_only_the_presenting_hosts_window() {
let (mut tree1, btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
tree1.click(btn1);
assert_eq!(
registry.live_count(),
1,
"the click must have enqueued a toast"
);
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree2.children(host2).len(),
0,
"window 2 never routed to must render nothing, regardless of reconcile order"
);
tree1.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree1.children(host1).len(),
1,
"window 1, the presenting window, must render its own toast"
);
}
#[test]
fn audience_targeted_toast_reaches_only_matching_hosts_regardless_of_reconcile_order() {
use teksilo_core::window::TeksiloWindowId;
let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
let audience = ToastAudience::new(7);
registry.set_window_audience(TeksiloWindowId::new(1), Some(audience));
tree1.layout(SizeProposal::exact(900.0, 600.0));
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(tree1.children(host1).len(), 0);
assert_eq!(tree2.children(host2).len(), 0);
registry.enqueue(Toast::info(LocalizedString::literal("scoped")).target(audience));
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree2.children(host2).len(),
0,
"window 2 has no matching audience and must render nothing"
);
tree1.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree1.children(host1).len(),
1,
"window 1, assigned the matching audience, must render the toast"
);
}
#[test]
fn audience_reassignment_is_observed_live_not_just_at_host_construction() {
use teksilo_core::window::TeksiloWindowId;
let (mut tree1, _btn1, host1, mut tree2, _btn2, host2, registry) = two_window_hosts();
let audience_a = ToastAudience::new(1);
let audience_b = ToastAudience::new(2);
registry.set_window_audience(TeksiloWindowId::new(1), Some(audience_a));
registry.set_window_audience(TeksiloWindowId::new(2), Some(audience_b));
tree1.layout(SizeProposal::exact(900.0, 600.0));
tree2.layout(SizeProposal::exact(900.0, 600.0));
registry.enqueue(Toast::info(LocalizedString::literal("for a")).target(audience_a));
tree1.layout(SizeProposal::exact(900.0, 600.0));
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree1.children(host1).len(),
1,
"window 1 (assigned audience A) renders the toast"
);
assert_eq!(
tree2.children(host2).len(),
0,
"window 2 (assigned audience B) must not render an A-targeted toast"
);
registry.set_window_audience(TeksiloWindowId::new(2), Some(audience_a));
tree2.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree2.children(host2).len(),
1,
"after reassigning window 2 to audience A, the still-live toast must now \
render there too — proves the audience signal is live, not read once"
);
tree1.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(tree1.children(host1).len(), 1);
}
#[test]
fn per_audience_burst_does_not_starve_another_audiences_rendered_slot() {
use teksilo_core::window::TeksiloWindowId;
let o = ToastInstallOptions {
archive: None,
max_visible: 2,
..ToastInstallOptions::default()
};
let (mut tree_a, _btn_a, host_a, mut tree_b, _btn_b, host_b, registry) =
two_window_hosts_with_opts(o);
let audience_a = ToastAudience::new(1);
let audience_b = ToastAudience::new(2);
registry.set_window_audience(TeksiloWindowId::new(1), Some(audience_a));
registry.set_window_audience(TeksiloWindowId::new(2), Some(audience_b));
tree_a.layout(SizeProposal::exact(900.0, 600.0));
tree_b.layout(SizeProposal::exact(900.0, 600.0));
registry.enqueue(Toast::info(LocalizedString::literal("a1")).target(audience_a));
registry.enqueue(Toast::info(LocalizedString::literal("a2")).target(audience_a));
let (a3, _) =
registry.enqueue(Toast::info(LocalizedString::literal("a3")).target(audience_a));
assert!(
!a3.is_alive(),
"audience A's third toast overflows its own bucket (sanity check on the burst)"
);
registry.enqueue(Toast::info(LocalizedString::literal("b1")).target(audience_b));
tree_a.layout(SizeProposal::exact(900.0, 600.0));
tree_b.layout(SizeProposal::exact(900.0, 600.0));
assert_eq!(
tree_a.children(host_a).len(),
2,
"audience A's host renders exactly its own capped bucket (2), not the overflowed 3rd"
);
assert_eq!(
tree_b.children(host_b).len(),
1,
"audience B's toast is still admitted AND RENDERED in B's own host — A's \
burst must not starve B's rendered slot"
);
}
}