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
12use std::rc::Rc;
13
14use teksilo_canvas::{Rect, SizeProposal};
15use teksilo_core::accessibility::AccessNodeBuilder;
16use teksilo_core::build_context::BuildContext;
17use teksilo_core::styles::{SharedToastStyle, ToastStyleConfig};
18use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
19use teksilo_core::widget_id::WidgetId;
20use teksilo_tokens::{TextRole, TextStyleRole};
21
22use crate::button::Button;
23use crate::icon_button::IconButton;
24use crate::link::Link;
25use crate::primitives::{HStack, Spacer, TextWidget, VStack};
26use crate::severity_badge::SeverityBadge;
27use crate::styles::recipe_toast_style as toast_tokens;
28use crate::toast::registry::ToastRegistry;
29use crate::toast::{
30    DEFAULT_TOAST_AUTO_DISMISS, ToastAction, ToastActionStyle, ToastDismissCause, ToastSeverity,
31};
32use teksilo_i18n::LocalizedString;
33
34/// Snapshot data passed to a `ToastSurface` for one live entry. Owned
35/// by the host's `LiveEntry` and cloned into the surface at build
36/// time. `Rc<...>` fields keep callbacks cheap to copy.
37#[derive(Clone)]
38pub struct ToastSurfaceData {
39    pub entry_id: u64,
40    pub severity: ToastSeverity,
41    pub priority: teksilo_core::styles::ToastPriority,
42    pub title: LocalizedString,
43    pub body: Option<LocalizedString>,
44    pub announcement: Option<LocalizedString>,
45    pub actions: Rc<Vec<ToastAction>>,
46    pub show_close_button: bool,
47    pub on_click: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
48    pub style_override: Option<SharedToastStyle>,
49    /// Clamped / unfolded state of the body, owned by the live entry so it survives the
50    /// host's surface rebuilds — see `LiveEntry::body_state`.
51    pub body_state: teksilo_core::signal::Signal<u8>,
52}
53
54impl std::fmt::Debug for ToastSurfaceData {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("ToastSurfaceData")
57            .field("entry_id", &self.entry_id)
58            .field("severity", &self.severity)
59            .field("priority", &self.priority)
60            .field("title", &self.title)
61            .field("body", &self.body)
62            .field("actions_count", &self.actions.len())
63            .field("show_close", &self.show_close_button)
64            .finish()
65    }
66}
67
68/// One rendered toast — chrome owned by `ToastStyle::make_body`,
69/// functional pieces (glyph, body, action row, close button) owned
70/// by this widget. Built fresh for each entry — there is no internal
71/// `Signal<Option<…>>` slot binding (the host rebuilds on changes).
72pub struct ToastSurface {
73    data: ToastSurfaceData,
74    leading_widget: Option<Box<dyn Widget>>,
75    registry: ToastRegistry,
76    /// Captured at build time so timer + close handler don't need a
77    /// theme lookup at fire-time. `None` under reduced-motion.
78    closable_on_escape: bool,
79    root_child_id: Option<WidgetId>,
80}
81
82impl std::fmt::Debug for ToastSurface {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("ToastSurface")
85            .field("data", &self.data)
86            .field("closable_on_escape", &self.closable_on_escape)
87            .finish()
88    }
89}
90
91impl ToastSurface {
92    /// Build a surface for a single live toast entry. Called by [`ToastHost`](crate::toast::host::ToastHost)
93    /// once per live registry entry during each rebuild pass. `leading_widget` is `Some` for
94    /// `Toast::loading` (a spinner) and `None` for severity-glyph entries (a `SeverityBadge`
95    /// is synthesised in `build`). `closable_on_escape` mirrors the matching `Toast` field.
96    pub fn new(
97        data: ToastSurfaceData,
98        leading_widget: Option<Box<dyn Widget>>,
99        registry: ToastRegistry,
100        closable_on_escape: bool,
101    ) -> Self {
102        Self {
103            data,
104            leading_widget,
105            registry,
106            closable_on_escape,
107            root_child_id: None,
108        }
109    }
110
111    /// AT role per the severity × priority matrix.
112    fn at_role(&self) -> teksilo_core::accesskit::Role {
113        use teksilo_core::styles::ToastPriority;
114        let elevated_priority = matches!(
115            self.data.priority,
116            ToastPriority::High | ToastPriority::Urgent
117        );
118        match (self.data.severity, elevated_priority) {
119            (ToastSeverity::Error, _) => teksilo_core::accesskit::Role::Alert,
120            (ToastSeverity::Warning, true) => teksilo_core::accesskit::Role::Alert,
121            _ => teksilo_core::accesskit::Role::Status,
122        }
123    }
124
125    /// `Live::Polite` for Status, `Live::Assertive` for Alert.
126    /// `Urgent` priority forces Assertive regardless of severity.
127    fn at_live(&self) -> teksilo_core::accesskit::Live {
128        use teksilo_core::styles::ToastPriority;
129        if matches!(self.data.priority, ToastPriority::Urgent) {
130            return teksilo_core::accesskit::Live::Assertive;
131        }
132        match self.at_role() {
133            teksilo_core::accesskit::Role::Alert => teksilo_core::accesskit::Live::Assertive,
134            _ => teksilo_core::accesskit::Live::Polite,
135        }
136    }
137
138    fn build_action_widget(
139        &self,
140        ctx: &mut BuildContext,
141        action: &ToastAction,
142        entry_id: u64,
143        registry: ToastRegistry,
144    ) -> WidgetId {
145        let callback = action.callback();
146        let closes_toast = action.closes_toast_flag();
147        let label_owned = action.label_ls();
148        let tooltip_owned = action.tooltip_ref().cloned();
149        let registry_for_handler = registry.clone();
150        let activate = move |ctx: &mut teksilo_core::widget::EventContext| {
151            callback(ctx);
152            if closes_toast {
153                registry_for_handler.dismiss_entry(entry_id, ToastDismissCause::ActionInvoked, ctx);
154            }
155        };
156        // `shortcut_id` is stored on the action for archive replay —
157        // NotificationLog renders archived entries' actions as
158        // `ctx.send_intent(Intent::by_name(id))` buttons. For the
159        // live toast itself the action's own callback is the source
160        // of truth, so we don't wire shortcut_id into the rendered
161        // widget here.
162        match action.style_ref() {
163            ToastActionStyle::Link => {
164                let mut link = Link::new(label_owned).on_activate_fn(activate);
165                if let Some(tip) = tooltip_owned {
166                    link = link.tooltip(tip);
167                }
168                ctx.add(link)
169            }
170            ToastActionStyle::Button { variant } => {
171                let mut btn = Button::new(label_owned)
172                    .variant(*variant)
173                    .on_activate_fn(activate);
174                if let Some(tip) = tooltip_owned {
175                    btn = btn.tooltip(tip);
176                }
177                ctx.add(btn)
178            }
179        }
180    }
181}
182
183impl Widget for ToastSurface {
184    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
185        let severity = self.data.severity;
186        let entry_id = self.data.entry_id;
187        let registry = self.registry.clone();
188
189        // Leading: custom widget (loading spinner, app icon) or the
190        // default severity glyph.
191        let leading_id = match self.leading_widget.take() {
192            Some(w) => ctx.add_boxed(w),
193            None => ctx.add(SeverityBadge::new(
194                severity.into(),
195                toast_tokens::TOAST_GLYPH_SIZE,
196            )),
197        };
198
199        // Title + optional body column.
200        let title = ctx.add(
201            TextWidget::new(self.data.title.clone())
202                .style(TextStyleRole::BodyBold)
203                .color(TextRole::Primary)
204                .single_line(),
205        );
206        let mut text_column = VStack::new()
207            .spacing(toast_tokens::TOAST_TITLE_BODY_GAP)
208            .add_child(title);
209        if let Some(body) = &self.data.body {
210            // Not a bare `TextWidget`: a body is whatever the app hands over, and apps
211            // hand over error text with no length bound. `CollapsibleBody` clamps it and
212            // grows a disclosure row only when clamping actually hid something — see its
213            // module docs.
214            let registry_for_expand = registry.clone();
215            let body_widget = ctx.add(
216                crate::toast::body::CollapsibleBody::new(
217                    body.clone(),
218                    self.data.body_state.clone(),
219                )
220                .on_expand(move || registry_for_expand.cancel_auto_dismiss(entry_id)),
221            );
222            text_column = text_column.add_child(body_widget);
223        }
224        let text_column_id = ctx.add(text_column);
225
226        // Build action widgets — Links inline, Buttons in a footer row.
227        let mut inline_link_ids: Vec<WidgetId> = Vec::new();
228        let mut footer_button_ids: Vec<WidgetId> = Vec::new();
229        for action in self.data.actions.iter() {
230            let widget_id = self.build_action_widget(ctx, action, entry_id, registry.clone());
231            match action.style_ref() {
232                ToastActionStyle::Link => inline_link_ids.push(widget_id),
233                ToastActionStyle::Button { .. } => footer_button_ids.push(widget_id),
234            }
235        }
236
237        // Body column: text + inline link row (if any) + footer row (if any).
238        let mut body_column = VStack::new()
239            .spacing(toast_tokens::TOAST_BODY_ACTIONS_GAP)
240            .add_child(text_column_id);
241        if !inline_link_ids.is_empty() {
242            let mut link_row = HStack::new().spacing(toast_tokens::TOAST_CONTENT_GAP);
243            for id in inline_link_ids {
244                link_row = link_row.add_child(id);
245            }
246            body_column = body_column.add_child(ctx.add(link_row));
247        }
248        if !footer_button_ids.is_empty() {
249            let spacer_id = ctx.add(Spacer::new());
250            let mut footer = HStack::new()
251                .spacing(toast_tokens::TOAST_CONTENT_GAP)
252                .add_child(spacer_id);
253            for id in footer_button_ids {
254                footer = footer.add_child(id);
255            }
256            body_column = body_column.add_child(ctx.add(footer));
257        }
258        let body_id = ctx.add(body_column);
259
260        // Optional close (X) button — registered as a CustomAction
261        // on the AT node too (Action::Dismiss does not exist in
262        // AccessKit 0.24).
263        let close_id = if self.data.show_close_button {
264            let registry_for_close = registry.clone();
265            Some(
266                ctx.add(IconButton::clear().embedded().on_activate_fn(move |ctx| {
267                    registry_for_close.dismiss_entry(
268                        entry_id,
269                        ToastDismissCause::CloseClicked,
270                        ctx,
271                    );
272                })),
273            )
274        } else {
275            None
276        };
277
278        // Delegate chrome to the active ToastStyle.
279        let style: SharedToastStyle = self
280            .data
281            .style_override
282            .clone()
283            .or_else(|| ctx.theme().style_slots.toast.clone())
284            .unwrap_or_else(|| Rc::new(crate::styles::RecipeToastStyle::default()));
285        let root = style.make_body(
286            &ToastStyleConfig {
287                severity,
288                priority: self.data.priority,
289                content: body_id,
290                leading_glyph: leading_id,
291                trailing_close: close_id,
292            },
293            ctx,
294        );
295
296        // Hover-pause: any pointer entering the toast bumps the host's
297        // `hover_count` Signal up; leaving bumps it down. The host's
298        // frame-tick effect reads the count > 0 as "pause all timers".
299        // The handler attaches to the root chrome widget so the entire
300        // visible surface counts as hover area.
301        let hover_count = registry.hover_count_signal();
302        use teksilo_core::widget_builder::HandlerSet;
303        let mut handlers = HandlerSet::new().on_hover(move |entered, _ctx| {
304            let n = hover_count.get();
305            let next = if entered { n + 1 } else { n.saturating_sub(1) };
306            hover_count.set(next);
307        });
308
309        // Optional click-through-body callback.
310        if let Some(on_click) = self.data.on_click.clone() {
311            handlers = handlers
312                .on_tap(move |_event, ctx| on_click(ctx))
313                .cursor(teksilo_core::widget::CursorIcon::Pointer);
314        }
315
316        // Escape dismisses while focused — implemented via on_key so
317        // we don't have to register a tree-wide shortcut for every
318        // toast.
319        if self.closable_on_escape {
320            let registry_for_esc = registry.clone();
321            handlers = handlers.focusable(true).on_key(move |event, ctx| {
322                use teksilo_core::event::{EventResponse, Key, WidgetEvent};
323                match event {
324                    WidgetEvent::KeyDown {
325                        key: Key::Escape, ..
326                    } => {
327                        registry_for_esc.dismiss_entry(
328                            entry_id,
329                            ToastDismissCause::EscapePressed,
330                            ctx,
331                        );
332                        EventResponse::Handled
333                    }
334                    _ => EventResponse::Ignored,
335                }
336            });
337        }
338
339        ctx.apply_self_handlers(handlers);
340
341        self.root_child_id = Some(root);
342        vec![root]
343    }
344
345    fn layout_response(
346        &self,
347        proposal: SizeProposal,
348        ctx: &LayoutContext,
349    ) -> teksilo_core::widget::LayoutResponse {
350        self.root_child_id
351            .and_then(|id| ctx.child_size(id, proposal))
352            .unwrap_or_else(|| proposal.resolve(280.0, 56.0))
353            .into()
354    }
355
356    fn place_children(
357        &self,
358        bounds: Rect,
359        _proposal: SizeProposal,
360        children: &mut [WidgetPlacement],
361        _ctx: &LayoutContext,
362    ) {
363        for child in children.iter_mut() {
364            child.origin = bounds.origin();
365            child.size = bounds.size();
366        }
367    }
368
369    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
370        builder.set_role(self.at_role());
371        builder.set_live(self.at_live());
372        // `live_atomic` so the whole title+body is announced as one
373        // unit — matches `aria-atomic=true` default for role=alert /
374        // role=status.
375        builder.inner_mut().set_live_atomic();
376        // Name = announcement override or visible title (matches
377        // Snackbar / Banner convention).
378        let name = self
379            .data
380            .announcement
381            .as_ref()
382            .map(|a| a.resolve_now())
383            .unwrap_or_else(|| self.data.title.resolve_now());
384        builder.set_name(name);
385        if let Some(body) = &self.data.body {
386            builder.set_description(body.resolve_now());
387        }
388    }
389
390    fn children(&self) -> Vec<WidgetId> {
391        self.root_child_id.into_iter().collect()
392    }
393}
394
395/// Convert milliseconds to a Duration — used for default tests.
396#[doc(hidden)]
397pub fn _default_dismiss() -> std::time::Duration {
398    DEFAULT_TOAST_AUTO_DISMISS
399}