Skip to main content

teksilo_widgets/toast/
surface.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ToastSurface` — the rendered chrome of one toast.
5//!
6//! Built by `ToastHost` for each live entry. Owns the severity
7//! glyph, title + body column, action row, close button, and the
8//! `Role::Alert` / `Role::Status` AccessKit node mapping. The visual
9//! chrome (background, padding, layout) is delegated to the active
10//! `ToastStyle` via `make_body`.
11//!
12//! An update in place (`Toast::id`) rebuilds the surface of that one
13//! entry, and the rebuild **reconciles**: the surface's own node stays
14//! (so the toast is announced again only if its name changed), and the
15//! glyph, title, body, each action and the close button are kept when
16//! the update left them as they were. A progress toast updated every
17//! 160 ms therefore keeps the Cancel a keyboard user is on, and a kept
18//! action calls the action of the latest update, not of the one that
19//! built it.
20
21use std::cell::{Cell, RefCell};
22use std::rc::Rc;
23
24use teksilo_canvas::{Rect, SizeProposal};
25use teksilo_core::accessibility::AccessNodeBuilder;
26use teksilo_core::binding::BindingLevel;
27use teksilo_core::build_context::BuildContext;
28use teksilo_core::styles::{SharedToastStyle, ToastStyleConfig};
29use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
30use teksilo_core::widget_id::WidgetId;
31use teksilo_tokens::{TextRole, TextStyleRole};
32
33use crate::button::Button;
34use crate::icon_button::IconButton;
35use crate::link::Link;
36use crate::primitives::{HStack, Spacer, TextWidget, VStack};
37use crate::severity_badge::SeverityBadge;
38use crate::styles::recipe_toast_style as toast_tokens;
39use crate::toast::registry::ToastRegistry;
40use crate::toast::{
41    DEFAULT_TOAST_AUTO_DISMISS, ToastAction, ToastActionStyle, ToastDismissCause, ToastSeverity,
42};
43use teksilo_i18n::LocalizedString;
44
45/// Snapshot data passed to a `ToastSurface` for one live entry. Owned
46/// by the host's `LiveEntry` and cloned into the surface at build
47/// time. `Rc<...>` fields keep callbacks cheap to copy.
48#[derive(Clone)]
49pub struct ToastSurfaceData {
50    pub entry_id: u64,
51    pub severity: ToastSeverity,
52    pub priority: teksilo_core::styles::ToastPriority,
53    pub title: LocalizedString,
54    pub body: Option<LocalizedString>,
55    pub announcement: Option<LocalizedString>,
56    pub actions: Rc<Vec<ToastAction>>,
57    pub show_close_button: bool,
58    pub on_click: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
59    pub style_override: Option<SharedToastStyle>,
60    /// Clamped / unfolded state of the body, owned by the live entry so it survives the
61    /// host's surface rebuilds — see `LiveEntry::body_state`.
62    pub body_state: teksilo_core::signal::Signal<u8>,
63}
64
65impl std::fmt::Debug for ToastSurfaceData {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("ToastSurfaceData")
68            .field("entry_id", &self.entry_id)
69            .field("severity", &self.severity)
70            .field("priority", &self.priority)
71            .field("title", &self.title)
72            .field("body", &self.body)
73            .field("actions_count", &self.actions.len())
74            .field("show_close", &self.show_close_button)
75            .finish()
76    }
77}
78
79/// One rendered toast — chrome owned by `ToastStyle::make_body`,
80/// functional pieces (glyph, body, action row, close button) owned
81/// by this widget. Built once per entry by the host; an update in
82/// place rebuilds it and keeps what did not change (see the module
83/// docs).
84pub struct ToastSurface {
85    data: ToastSurfaceData,
86    leading_widget: Option<Box<dyn Widget>>,
87    registry: ToastRegistry,
88    /// Captured at build time so timer + close handler don't need a
89    /// theme lookup at fire-time. `None` under reduced-motion.
90    closable_on_escape: bool,
91    root_child_id: Option<WidgetId>,
92    /// What the last build made, for the next build to keep.
93    built: Option<BuiltParts>,
94    /// The entry's current actions. A rendered action calls the one at its
95    /// index here, so an action widget kept across an update runs the
96    /// update's callback.
97    current_actions: Rc<RefCell<Rc<Vec<ToastAction>>>>,
98    /// Focus on the surface itself, and on anything inside it. Fields, not
99    /// per-build signals: a rebuild with focus inside must not read as focus
100    /// having left.
101    focused_self: teksilo_core::signal::Signal<bool>,
102    focus_within: teksilo_core::signal::Signal<bool>,
103    /// Whether this surface last told the registry that focus is inside it.
104    /// The registry counts holders across windows, so a surface reports only
105    /// a change, and reports focus leaving when it is dropped with focus
106    /// inside (see the `Drop` impl).
107    focus_reported: Rc<Cell<bool>>,
108}
109
110/// Tell the registry about a change of focus inside `entry_id`'s surface,
111/// once per change.
112fn report_focus(registry: &ToastRegistry, entry_id: u64, reported: &Cell<bool>, focused: bool) {
113    if reported.replace(focused) != focused {
114        registry.set_entry_focused(entry_id, focused);
115    }
116}
117
118/// The pieces a surface build made, and what each was made from.
119struct BuiltParts {
120    severity: ToastSeverity,
121    priority: teksilo_core::styles::ToastPriority,
122    style_override: Option<SharedToastStyle>,
123    leading: WidgetId,
124    title: (String, WidgetId),
125    body: Option<(String, WidgetId)>,
126    actions: Vec<(ActionKey, WidgetId)>,
127    close: Option<WidgetId>,
128    root: WidgetId,
129}
130
131/// What an action widget is made from: another action with the same key
132/// renders the same widget.
133#[derive(PartialEq)]
134struct ActionKey {
135    label: String,
136    tooltip: Option<String>,
137    button: Option<crate::button::ButtonVariant>,
138}
139
140impl ActionKey {
141    fn of(action: &ToastAction) -> Self {
142        Self {
143            label: action.label(),
144            tooltip: action.tooltip_ref().map(|t| t.resolve_now()),
145            button: match action.style_ref() {
146                ToastActionStyle::Link => None,
147                ToastActionStyle::Button { variant } => Some(*variant),
148            },
149        }
150    }
151}
152
153impl std::fmt::Debug for ToastSurface {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.debug_struct("ToastSurface")
156            .field("data", &self.data)
157            .field("closable_on_escape", &self.closable_on_escape)
158            .finish()
159    }
160}
161
162impl ToastSurface {
163    /// Build a surface for a single live toast entry. Called by [`ToastHost`](crate::toast::host::ToastHost)
164    /// once per live registry entry during each rebuild pass. `leading_widget` is `Some` for
165    /// `Toast::loading` (a spinner) and `None` for severity-glyph entries (a `SeverityBadge`
166    /// is synthesised in `build`). `closable_on_escape` mirrors the matching `Toast` field.
167    pub fn new(
168        data: ToastSurfaceData,
169        leading_widget: Option<Box<dyn Widget>>,
170        registry: ToastRegistry,
171        closable_on_escape: bool,
172    ) -> Self {
173        Self {
174            current_actions: Rc::new(RefCell::new(data.actions.clone())),
175            data,
176            leading_widget,
177            registry,
178            closable_on_escape,
179            root_child_id: None,
180            built: None,
181            focused_self: teksilo_core::signal::Signal::new(false),
182            focus_within: teksilo_core::signal::Signal::new(false),
183            focus_reported: Rc::new(Cell::new(false)),
184        }
185    }
186
187    /// AT role per the severity × priority matrix.
188    fn at_role(&self) -> teksilo_core::accesskit::Role {
189        use teksilo_core::styles::ToastPriority;
190        let elevated_priority = matches!(
191            self.data.priority,
192            ToastPriority::High | ToastPriority::Urgent
193        );
194        match (self.data.severity, elevated_priority) {
195            (ToastSeverity::Error, _) => teksilo_core::accesskit::Role::Alert,
196            (ToastSeverity::Warning, true) => teksilo_core::accesskit::Role::Alert,
197            _ => teksilo_core::accesskit::Role::Status,
198        }
199    }
200
201    /// `Live::Polite` for Status, `Live::Assertive` for Alert.
202    /// `Urgent` priority forces Assertive regardless of severity.
203    fn at_live(&self) -> teksilo_core::accesskit::Live {
204        use teksilo_core::styles::ToastPriority;
205        if matches!(self.data.priority, ToastPriority::Urgent) {
206            return teksilo_core::accesskit::Live::Assertive;
207        }
208        match self.at_role() {
209            teksilo_core::accesskit::Role::Alert => teksilo_core::accesskit::Live::Assertive,
210            _ => teksilo_core::accesskit::Live::Polite,
211        }
212    }
213
214    fn build_action_widget(
215        &self,
216        ctx: &mut BuildContext,
217        action: &ToastAction,
218        index: usize,
219        entry_id: u64,
220        registry: ToastRegistry,
221    ) -> WidgetId {
222        let current_actions = self.current_actions.clone();
223        let label_owned = action.label_ls();
224        let tooltip_owned = action.tooltip_ref().cloned();
225        let registry_for_handler = registry.clone();
226        let activate = move |ctx: &mut teksilo_core::widget::EventContext| {
227            // The action at this index *now*: the widget may have been kept
228            // across updates that each brought a new callback.
229            let Some((callback, closes_toast)) = current_actions
230                .borrow()
231                .get(index)
232                .map(|a| (a.callback(), a.closes_toast_flag()))
233            else {
234                return;
235            };
236            callback(ctx);
237            if closes_toast {
238                registry_for_handler.dismiss_entry(entry_id, ToastDismissCause::ActionInvoked, ctx);
239            }
240        };
241        // `shortcut_id` is stored on the action for archive replay —
242        // NotificationLog renders archived entries' actions as
243        // `ctx.send_intent(Intent::by_name(id))` buttons. For the
244        // live toast itself the action's own callback is the source
245        // of truth, so we don't wire shortcut_id into the rendered
246        // widget here.
247        match action.style_ref() {
248            ToastActionStyle::Link => {
249                let mut link = Link::new(label_owned).on_activate_fn(activate);
250                if let Some(tip) = tooltip_owned {
251                    link = link.tooltip(tip);
252                }
253                ctx.add(link)
254            }
255            ToastActionStyle::Button { variant } => {
256                let mut btn = Button::new(label_owned)
257                    .variant(*variant)
258                    .on_activate_fn(activate);
259                if let Some(tip) = tooltip_owned {
260                    btn = btn.tooltip(tip);
261                }
262                ctx.add(btn)
263            }
264        }
265    }
266}
267
268impl ToastSurface {
269    /// Lay the parts out in their rows and hand them to the active
270    /// `ToastStyle` for the chrome. The containers are new on every call;
271    /// the parts may be kept ones, which the containers adopt.
272    fn make_chrome(
273        &self,
274        ctx: &mut BuildContext,
275        leading: WidgetId,
276        title: WidgetId,
277        body: Option<WidgetId>,
278        actions: &[(ActionKey, WidgetId)],
279        close: Option<WidgetId>,
280    ) -> WidgetId {
281        let mut text_column = VStack::new()
282            .spacing(toast_tokens::TOAST_TITLE_BODY_GAP)
283            .child(title);
284        if let Some(body) = body {
285            text_column = text_column.child(body);
286        }
287        let text_column_id = ctx.add(text_column);
288
289        // Body column: text + inline link row (if any) + footer row (if any).
290        let (links, buttons): (Vec<_>, Vec<_>) =
291            actions.iter().partition(|(key, _)| key.button.is_none());
292        let mut body_column = VStack::new()
293            .spacing(toast_tokens::TOAST_BODY_ACTIONS_GAP)
294            .child(text_column_id);
295        if !links.is_empty() {
296            let mut link_row = HStack::new().spacing(toast_tokens::TOAST_CONTENT_GAP);
297            for (_, id) in links {
298                link_row = link_row.child(*id);
299            }
300            body_column = body_column.child(ctx.add(link_row));
301        }
302        if !buttons.is_empty() {
303            let spacer_id = ctx.add(Spacer::new());
304            let mut footer = HStack::new()
305                .spacing(toast_tokens::TOAST_CONTENT_GAP)
306                .child(spacer_id);
307            for (_, id) in buttons {
308                footer = footer.child(*id);
309            }
310            body_column = body_column.child(ctx.add(footer));
311        }
312        let body_id = ctx.add(body_column);
313
314        // Delegate chrome to the active ToastStyle.
315        let style: SharedToastStyle = self
316            .data
317            .style_override
318            .clone()
319            .or_else(|| ctx.theme().style_slots.toast.clone())
320            .unwrap_or_else(|| {
321                Rc::new(crate::styles::RecipeToastStyle::for_tokens(
322                    &ctx.theme().input,
323                ))
324            });
325        style.make_body(
326            &ToastStyleConfig {
327                severity: self.data.severity,
328                priority: self.data.priority,
329                content: body_id,
330                leading_glyph: leading,
331                trailing_close: close,
332            },
333            ctx,
334        )
335    }
336}
337
338impl Widget for ToastSurface {
339    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
340        let entry_id = self.data.entry_id;
341        let registry = self.registry.clone();
342
343        // The entry's latest data: this build may be an update in place.
344        // A surface built for no live entry (a test) keeps what it was given.
345        if let Some((data, closable_on_escape)) = registry.surface_data(entry_id) {
346            self.data = data;
347            self.closable_on_escape = closable_on_escape;
348        }
349        if let Some(revision) = registry.entry_revision(entry_id) {
350            revision.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
351        }
352        *self.current_actions.borrow_mut() = self.data.actions.clone();
353        let severity = self.data.severity;
354        let previous = self.built.take();
355
356        // Leading: custom widget (loading spinner, app icon) or the
357        // default severity glyph. A new custom widget arrives with the
358        // entry (`take_leading`); without one the previous glyph stays
359        // unless the severity changed, which is when the registry drops a
360        // custom one too.
361        let leading = match self
362            .leading_widget
363            .take()
364            .or_else(|| registry.take_leading(entry_id))
365        {
366            Some(w) => ctx.add_boxed(w),
367            None => match &previous {
368                Some(p) if p.severity == severity => p.leading,
369                _ => ctx.add(SeverityBadge::new(
370                    severity.into(),
371                    toast_tokens::TOAST_GLYPH_SIZE,
372                )),
373            },
374        };
375
376        // Title + optional body column. The toast's own AT node (see `accessibility`
377        // below) already carries this text as its name — a live region reads its own
378        // value/label, not a `labelled_by` relation — so the title label would repeat
379        // it; hide it from AT.
380        let title_text = self.data.title.resolve_now();
381        let title = match &previous {
382            Some(p) if p.title.0 == title_text => p.title.1,
383            _ => ctx.add(
384                TextWidget::new(self.data.title.clone())
385                    .style(TextStyleRole::BodyBold)
386                    .color(TextRole::Primary)
387                    .single_line()
388                    .a11y_hidden(),
389            ),
390        };
391        let body = self.data.body.as_ref().map(|body| {
392            let body_text = body.resolve_now();
393            match previous.as_ref().and_then(|p| p.body.as_ref()) {
394                Some((text, id)) if *text == body_text => (body_text, *id),
395                // Not a bare `TextWidget`: a body is whatever the app hands over, and
396                // apps hand over error text with no length bound. `CollapsibleBody`
397                // clamps it and grows a disclosure row only when clamping actually hid
398                // something — see its module docs.
399                _ => {
400                    let registry_for_expand = registry.clone();
401                    let id = ctx.add(
402                        crate::toast::body::CollapsibleBody::new(
403                            body.clone(),
404                            self.data.body_state.clone(),
405                        )
406                        .on_expand(move || registry_for_expand.cancel_auto_dismiss(entry_id)),
407                    );
408                    (body_text, id)
409                }
410            }
411        });
412
413        // Action widgets: Links inline, Buttons in a footer row. One the
414        // update left as it was is kept, with focus if it had it.
415        let mut actions: Vec<(ActionKey, WidgetId)> = Vec::with_capacity(self.data.actions.len());
416        for (index, action) in self.data.actions.clone().iter().enumerate() {
417            let key = ActionKey::of(action);
418            let kept = previous
419                .as_ref()
420                .and_then(|p| p.actions.get(index))
421                .filter(|(previous_key, _)| *previous_key == key)
422                .map(|(_, id)| *id);
423            let id = match kept {
424                Some(id) => id,
425                None => self.build_action_widget(ctx, action, index, entry_id, registry.clone()),
426            };
427            actions.push((key, id));
428        }
429
430        // Optional close (X) button — registered as a CustomAction
431        // on the AT node too (Action::Dismiss does not exist in
432        // AccessKit 0.24).
433        let close = if self.data.show_close_button {
434            match previous.as_ref().and_then(|p| p.close) {
435                Some(id) => Some(id),
436                None => {
437                    let registry_for_close = registry.clone();
438                    Some(
439                        ctx.add(IconButton::clear().embedded().on_activate_fn(move |ctx| {
440                            registry_for_close.dismiss_entry(
441                                entry_id,
442                                ToastDismissCause::CloseClicked,
443                                ctx,
444                            );
445                        })),
446                    )
447                }
448            }
449        } else {
450            None
451        };
452
453        let same_style = |p: &BuiltParts| match (&p.style_override, &self.data.style_override) {
454            (None, None) => true,
455            (Some(a), Some(b)) => Rc::ptr_eq(a, b),
456            _ => false,
457        };
458        let unchanged = previous.as_ref().filter(|p| {
459            p.severity == severity
460                && p.priority == self.data.priority
461                && same_style(p)
462                && p.leading == leading
463                && p.title.1 == title
464                && p.body.as_ref().map(|b| b.1) == body.as_ref().map(|b| b.1)
465                && p.actions
466                    .iter()
467                    .map(|a| a.1)
468                    .eq(actions.iter().map(|a| a.1))
469                && p.close == close
470        });
471        let root = match unchanged {
472            // Nothing to lay out anew: keep the chrome and everything in it.
473            Some(p) => p.root,
474            None => self.make_chrome(
475                ctx,
476                leading,
477                title,
478                body.as_ref().map(|b| b.1),
479                &actions,
480                close,
481            ),
482        };
483        self.built = Some(BuiltParts {
484            severity,
485            priority: self.data.priority,
486            style_override: self.data.style_override.clone(),
487            leading,
488            title: (title_text, title),
489            body,
490            actions,
491            close,
492            root,
493        });
494
495        // Hover-pause: any pointer entering the toast bumps the host's
496        // `hover_count` Signal up; leaving bumps it down. The host's
497        // frame-tick effect reads the count > 0 as "pause all timers".
498        // The handler attaches to the root chrome widget so the entire
499        // visible surface counts as hover area.
500        let hover_count = registry.hover_count_signal();
501        use teksilo_core::widget_builder::HandlerSet;
502        let mut handlers = HandlerSet::new().on_hover(move |entered, _ctx| {
503            let n = hover_count.get();
504            let next = if entered { n + 1 } else { n.saturating_sub(1) };
505            hover_count.set(next);
506        });
507
508        // Hold-pause: the touch twin of the hover pause. A finger produces no
509        // hover, so pressing and holding the surface is how a touch user says
510        // "wait". Driven from the **framework's** press signal rather than from
511        // a `PointerDown`/`PointerUp` pair of our own, because the framework is
512        // the only thing that also clears it for a contact the system revokes —
513        // and a hold that is never released would stop every toast in the
514        // application from ever expiring.
515        //
516        // Kind-agnostic on purpose: a mouse press is already hovering, so the
517        // pause it adds changes nothing a mouse could observe.
518        {
519            let pressed = ctx.pressed_signal();
520            let registry_for_hold = registry.clone();
521            registry_for_hold.set_entry_held(entry_id, pressed.get());
522            let registry_for_effect = registry.clone();
523            ctx.effect(&pressed, move |held| {
524                registry_for_effect.set_entry_held(entry_id, *held);
525            });
526        }
527
528        // Focus-pause: the keyboard twin of the hover pause. A reader who has
529        // Tabbed onto a toast, or to its action, is reading it or deciding; a
530        // toast that expires then takes focus with it (WCAG 2.2.1). The
531        // framework writes `focus_within` for a strict descendant only, so
532        // focus on the surface itself comes from its own `on_focus`. Only
533        // focus that came from the keyboard or a screen reader: a pointer
534        // already pauses by resting on the toast (or holding it), and a click
535        // that left focus behind has not asked the toast to stay.
536        {
537            let focused_self = self.focused_self.clone();
538            handlers = handlers
539                .focus_within(self.focus_within.clone())
540                .on_focus(move |gained, _ctx| focused_self.set(gained));
541            let inside = self
542                .focus_within
543                .or(&self.focused_self)
544                .and(&ctx.focus_visible());
545            report_focus(&registry, entry_id, &self.focus_reported, inside.get());
546            let registry_for_effect = registry.clone();
547            let reported = self.focus_reported.clone();
548            ctx.effect(&inside, move |focused| {
549                report_focus(&registry_for_effect, entry_id, &reported, *focused);
550            });
551        }
552
553        // Swipe-to-dismiss. The close button is small hover-adjacent chrome;
554        // a swipe is the whole surface and needs no aim, which is why every
555        // touch platform ships it. Horizontal only — a vertical swipe on a
556        // toast stack would fight whatever the stack is sitting over.
557        //
558        // **Named side effect on the mouse:** installing an `on_swipe` makes
559        // `ArenaRecognizers::accepted_buttons` return `ButtonMask::ALL` for this
560        // node, so a secondary or middle press now raises the framework press
561        // here where only a primary did before. On this surface that press
562        // drives nothing but the hold-pause above, so the visible consequence is
563        // that a right-press pauses the toast — which is what a pointer resting
564        // on it does anyway.
565        {
566            let registry_for_swipe = registry.clone();
567            handlers = handlers.on_swipe(move |direction, _velocity, ctx| {
568                use teksilo_core::gesture::SwipeDirection;
569                // Coarse pointers only — the affordance answers the absence of
570                // hover, and a mouse that happens to drag fast across a toast
571                // has not asked for anything. `is_coarse`, not `is_direct`: a
572                // pen is precise, hovers, and keeps its drag.
573                if ctx.pointer_kind().is_coarse()
574                    && matches!(direction, SwipeDirection::Left | SwipeDirection::Right)
575                {
576                    registry_for_swipe
577                        .dismiss_entry_deferred(entry_id, ToastDismissCause::SwipeDismissed);
578                }
579            });
580        }
581
582        // Optional click-through-body callback.
583        if let Some(on_click) = self.data.on_click.clone() {
584            handlers = handlers
585                .on_tap(move |_event, ctx| on_click(ctx))
586                .cursor(teksilo_core::widget::CursorIcon::Pointer);
587        }
588
589        // Escape dismisses while focused — implemented via on_key so
590        // we don't have to register a tree-wide shortcut for every
591        // toast.
592        if self.closable_on_escape {
593            let registry_for_esc = registry.clone();
594            handlers = handlers.focusable(true).on_key(move |event, ctx| {
595                use teksilo_core::event::{EventResponse, Key, WidgetEvent};
596                match event {
597                    WidgetEvent::KeyDown {
598                        key: Key::Escape, ..
599                    } => {
600                        registry_for_esc.dismiss_entry(
601                            entry_id,
602                            ToastDismissCause::EscapePressed,
603                            ctx,
604                        );
605                        EventResponse::Handled
606                    }
607                    _ => EventResponse::Ignored,
608                }
609            });
610        }
611
612        ctx.apply_self_handlers(handlers);
613
614        self.root_child_id = Some(root);
615        vec![root]
616    }
617
618    fn preserves_children_on_rebuild(&self) -> bool {
619        // An update in place keeps the parts it did not change; see the
620        // module docs.
621        true
622    }
623
624    fn layout_response(
625        &self,
626        proposal: SizeProposal,
627        ctx: &LayoutContext,
628    ) -> teksilo_core::widget::LayoutResponse {
629        self.root_child_id
630            .and_then(|id| ctx.child_size(id, proposal))
631            .unwrap_or_else(|| proposal.resolve(280.0, 56.0))
632            .into()
633    }
634
635    fn place_children(
636        &self,
637        bounds: Rect,
638        _proposal: SizeProposal,
639        children: &mut [WidgetPlacement],
640        _ctx: &LayoutContext,
641    ) {
642        for child in children.iter_mut() {
643            child.origin = bounds.origin();
644            child.size = bounds.size();
645        }
646    }
647
648    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
649        builder.set_role(self.at_role());
650        builder.set_live(self.at_live());
651        // `live_atomic` so the whole title+body is announced as one
652        // unit — matches `aria-atomic=true` default for role=alert /
653        // role=status.
654        builder.inner_mut().set_live_atomic();
655        // Name = announcement override or visible title (matches
656        // Snackbar / Banner convention).
657        let name = self
658            .data
659            .announcement
660            .as_ref()
661            .map(|a| a.resolve_now())
662            .unwrap_or_else(|| self.data.title.resolve_now());
663        builder.set_name(name);
664        if let Some(body) = &self.data.body {
665            builder.set_description(body.resolve_now());
666        }
667    }
668
669    fn children(&self) -> Vec<WidgetId> {
670        self.root_child_id.into_iter().collect()
671    }
672}
673
674impl Drop for ToastSurface {
675    fn drop(&mut self) {
676        // A closed window drops its tree whole, with no focus change, so a
677        // surface that held focus would pause every toast of every window for
678        // good. A surface that saw focus leave before it went has reported
679        // that already, and reports nothing here.
680        report_focus(
681            &self.registry,
682            self.data.entry_id,
683            &self.focus_reported,
684            false,
685        );
686    }
687}
688
689/// Convert milliseconds to a Duration — used for default tests.
690#[doc(hidden)]
691pub fn _default_dismiss() -> std::time::Duration {
692    DEFAULT_TOAST_AUTO_DISMISS
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use teksilo_core::accessibility::widget_id_to_node_id;
699    use teksilo_core::accesskit::Role;
700    use teksilo_core::widget_tree::WidgetTree;
701    use teksilo_i18n::lit;
702
703    fn fresh_registry() -> ToastRegistry {
704        ToastRegistry::new(crate::toast::host::ToastInstallOptions::default())
705    }
706
707    /// The title's `TextWidget` duplicates the toast's own announced name. Toast is a
708    /// live region, so it keeps `set_name` on its own node and hides the title label
709    /// instead: the adapters announce a live node when its own name changes, and
710    /// `accesskit_consumer` reports a node as changed only when its own data does, so a
711    /// name drawn through `labelled_by` would change unannounced. The body is a
712    /// different string and must stay reviewable.
713    #[test]
714    fn toast_title_label_is_hidden_but_the_toast_keeps_its_name_and_the_body_label() {
715        let data = ToastSurfaceData {
716            entry_id: 1,
717            severity: ToastSeverity::Info,
718            priority: teksilo_core::styles::ToastPriority::Normal,
719            title: lit!("Saved"),
720            body: Some(lit!("Your changes were saved.")),
721            announcement: None,
722            actions: Rc::new(Vec::new()),
723            show_close_button: false,
724            on_click: None,
725            style_override: None,
726            body_state: teksilo_core::signal::Signal::new(0),
727        };
728
729        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
730        let id = tree.add(ToastSurface::new(data, None, fresh_registry(), false));
731        tree.layout(SizeProposal::exact(320.0, 200.0));
732        let update = tree.sync_accessibility();
733
734        let toast_node_id = widget_id_to_node_id(id);
735        let (_, toast_node) = update
736            .nodes
737            .iter()
738            .find(|(nid, _)| *nid == toast_node_id)
739            .expect("toast surface node");
740        assert_eq!(
741            toast_node.label(),
742            Some("Saved"),
743            "hiding the title label must not take the toast's own announced name with it"
744        );
745
746        assert!(
747            !update.nodes.iter().any(|(_, n)| n.role() == Role::Label
748                && teksilo_core::accessibility::announced_text(n) == Some("Saved")),
749            "the title label must not survive as a duplicate announcement"
750        );
751
752        assert!(
753            update.nodes.iter().any(|(_, n)| n.role() == Role::Label
754                && teksilo_core::accessibility::announced_text(n)
755                    == Some("Your changes were saved.")),
756            "the body label is a different string from the title and must remain visible"
757        );
758    }
759}