1use 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#[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 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
68pub struct ToastSurface {
73 data: ToastSurfaceData,
74 leading_widget: Option<Box<dyn Widget>>,
75 registry: ToastRegistry,
76 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 builder.inner_mut().set_live_atomic();
376 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#[doc(hidden)]
397pub fn _default_dismiss() -> std::time::Duration {
398 DEFAULT_TOAST_AUTO_DISMISS
399}