1use 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#[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 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
79pub struct ToastSurface {
85 data: ToastSurfaceData,
86 leading_widget: Option<Box<dyn Widget>>,
87 registry: ToastRegistry,
88 closable_on_escape: bool,
91 root_child_id: Option<WidgetId>,
92 built: Option<BuiltParts>,
94 current_actions: Rc<RefCell<Rc<Vec<ToastAction>>>>,
98 focused_self: teksilo_core::signal::Signal<bool>,
102 focus_within: teksilo_core::signal::Signal<bool>,
103 focus_reported: Rc<Cell<bool>>,
108}
109
110fn 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
118struct 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#[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 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 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 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 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 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 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 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 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 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 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 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 _ => {
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 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 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 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 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 {
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 {
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(®istry, 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(®istry_for_effect, entry_id, &reported, *focused);
550 });
551 }
552
553 {
566 let registry_for_swipe = registry.clone();
567 handlers = handlers.on_swipe(move |direction, _velocity, ctx| {
568 use teksilo_core::gesture::SwipeDirection;
569 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 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 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 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 builder.inner_mut().set_live_atomic();
655 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 report_focus(
681 &self.registry,
682 self.data.entry_id,
683 &self.focus_reported,
684 false,
685 );
686 }
687}
688
689#[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 #[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}