use std::rc::Rc;
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::styles::{SharedToastStyle, ToastStyleConfig};
use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{TextRole, TextStyleRole};
use crate::button::Button;
use crate::icon_button::IconButton;
use crate::link::Link;
use crate::primitives::{HStack, Spacer, TextWidget, VStack};
use crate::severity_badge::SeverityBadge;
use crate::styles::recipe_toast_style as toast_tokens;
use crate::toast::registry::ToastRegistry;
use crate::toast::{
DEFAULT_TOAST_AUTO_DISMISS, ToastAction, ToastActionStyle, ToastDismissCause, ToastSeverity,
};
use teksilo_i18n::LocalizedString;
#[derive(Clone)]
pub struct ToastSurfaceData {
pub entry_id: u64,
pub severity: ToastSeverity,
pub priority: teksilo_core::styles::ToastPriority,
pub title: LocalizedString,
pub body: Option<LocalizedString>,
pub announcement: Option<LocalizedString>,
pub actions: Rc<Vec<ToastAction>>,
pub show_close_button: bool,
pub on_click: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
pub style_override: Option<SharedToastStyle>,
pub body_state: teksilo_core::signal::Signal<u8>,
}
impl std::fmt::Debug for ToastSurfaceData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToastSurfaceData")
.field("entry_id", &self.entry_id)
.field("severity", &self.severity)
.field("priority", &self.priority)
.field("title", &self.title)
.field("body", &self.body)
.field("actions_count", &self.actions.len())
.field("show_close", &self.show_close_button)
.finish()
}
}
pub struct ToastSurface {
data: ToastSurfaceData,
leading_widget: Option<Box<dyn Widget>>,
registry: ToastRegistry,
closable_on_escape: bool,
root_child_id: Option<WidgetId>,
}
impl std::fmt::Debug for ToastSurface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToastSurface")
.field("data", &self.data)
.field("closable_on_escape", &self.closable_on_escape)
.finish()
}
}
impl ToastSurface {
pub fn new(
data: ToastSurfaceData,
leading_widget: Option<Box<dyn Widget>>,
registry: ToastRegistry,
closable_on_escape: bool,
) -> Self {
Self {
data,
leading_widget,
registry,
closable_on_escape,
root_child_id: None,
}
}
fn at_role(&self) -> teksilo_core::accesskit::Role {
use teksilo_core::styles::ToastPriority;
let elevated_priority = matches!(
self.data.priority,
ToastPriority::High | ToastPriority::Urgent
);
match (self.data.severity, elevated_priority) {
(ToastSeverity::Error, _) => teksilo_core::accesskit::Role::Alert,
(ToastSeverity::Warning, true) => teksilo_core::accesskit::Role::Alert,
_ => teksilo_core::accesskit::Role::Status,
}
}
fn at_live(&self) -> teksilo_core::accesskit::Live {
use teksilo_core::styles::ToastPriority;
if matches!(self.data.priority, ToastPriority::Urgent) {
return teksilo_core::accesskit::Live::Assertive;
}
match self.at_role() {
teksilo_core::accesskit::Role::Alert => teksilo_core::accesskit::Live::Assertive,
_ => teksilo_core::accesskit::Live::Polite,
}
}
fn build_action_widget(
&self,
ctx: &mut BuildContext,
action: &ToastAction,
entry_id: u64,
registry: ToastRegistry,
) -> WidgetId {
let callback = action.callback();
let closes_toast = action.closes_toast_flag();
let label_owned = action.label_ls();
let tooltip_owned = action.tooltip_ref().cloned();
let registry_for_handler = registry.clone();
let activate = move |ctx: &mut teksilo_core::widget::EventContext| {
callback(ctx);
if closes_toast {
registry_for_handler.dismiss_entry(entry_id, ToastDismissCause::ActionInvoked, ctx);
}
};
match action.style_ref() {
ToastActionStyle::Link => {
let mut link = Link::new(label_owned).on_activate_fn(activate);
if let Some(tip) = tooltip_owned {
link = link.tooltip(tip);
}
ctx.add(link)
}
ToastActionStyle::Button { variant } => {
let mut btn = Button::new(label_owned)
.variant(*variant)
.on_activate_fn(activate);
if let Some(tip) = tooltip_owned {
btn = btn.tooltip(tip);
}
ctx.add(btn)
}
}
}
}
impl Widget for ToastSurface {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let severity = self.data.severity;
let entry_id = self.data.entry_id;
let registry = self.registry.clone();
let leading_id = match self.leading_widget.take() {
Some(w) => ctx.add_boxed(w),
None => ctx.add(SeverityBadge::new(
severity.into(),
toast_tokens::TOAST_GLYPH_SIZE,
)),
};
let title = ctx.add(
TextWidget::new(self.data.title.clone())
.style(TextStyleRole::BodyBold)
.color(TextRole::Primary)
.single_line(),
);
let mut text_column = VStack::new()
.spacing(toast_tokens::TOAST_TITLE_BODY_GAP)
.add_child(title);
if let Some(body) = &self.data.body {
let registry_for_expand = registry.clone();
let body_widget = ctx.add(
crate::toast::body::CollapsibleBody::new(
body.clone(),
self.data.body_state.clone(),
)
.on_expand(move || registry_for_expand.cancel_auto_dismiss(entry_id)),
);
text_column = text_column.add_child(body_widget);
}
let text_column_id = ctx.add(text_column);
let mut inline_link_ids: Vec<WidgetId> = Vec::new();
let mut footer_button_ids: Vec<WidgetId> = Vec::new();
for action in self.data.actions.iter() {
let widget_id = self.build_action_widget(ctx, action, entry_id, registry.clone());
match action.style_ref() {
ToastActionStyle::Link => inline_link_ids.push(widget_id),
ToastActionStyle::Button { .. } => footer_button_ids.push(widget_id),
}
}
let mut body_column = VStack::new()
.spacing(toast_tokens::TOAST_BODY_ACTIONS_GAP)
.add_child(text_column_id);
if !inline_link_ids.is_empty() {
let mut link_row = HStack::new().spacing(toast_tokens::TOAST_CONTENT_GAP);
for id in inline_link_ids {
link_row = link_row.add_child(id);
}
body_column = body_column.add_child(ctx.add(link_row));
}
if !footer_button_ids.is_empty() {
let spacer_id = ctx.add(Spacer::new());
let mut footer = HStack::new()
.spacing(toast_tokens::TOAST_CONTENT_GAP)
.add_child(spacer_id);
for id in footer_button_ids {
footer = footer.add_child(id);
}
body_column = body_column.add_child(ctx.add(footer));
}
let body_id = ctx.add(body_column);
let close_id = if self.data.show_close_button {
let registry_for_close = registry.clone();
Some(
ctx.add(IconButton::clear().embedded().on_activate_fn(move |ctx| {
registry_for_close.dismiss_entry(
entry_id,
ToastDismissCause::CloseClicked,
ctx,
);
})),
)
} else {
None
};
let style: SharedToastStyle = self
.data
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.toast.clone())
.unwrap_or_else(|| Rc::new(crate::styles::RecipeToastStyle::default()));
let root = style.make_body(
&ToastStyleConfig {
severity,
priority: self.data.priority,
content: body_id,
leading_glyph: leading_id,
trailing_close: close_id,
},
ctx,
);
let hover_count = registry.hover_count_signal();
use teksilo_core::widget_builder::HandlerSet;
let mut handlers = HandlerSet::new().on_hover(move |entered, _ctx| {
let n = hover_count.get();
let next = if entered { n + 1 } else { n.saturating_sub(1) };
hover_count.set(next);
});
if let Some(on_click) = self.data.on_click.clone() {
handlers = handlers
.on_tap(move |_event, ctx| on_click(ctx))
.cursor(teksilo_core::widget::CursorIcon::Pointer);
}
if self.closable_on_escape {
let registry_for_esc = registry.clone();
handlers = handlers.focusable(true).on_key(move |event, ctx| {
use teksilo_core::event::{EventResponse, Key, WidgetEvent};
match event {
WidgetEvent::KeyDown {
key: Key::Escape, ..
} => {
registry_for_esc.dismiss_entry(
entry_id,
ToastDismissCause::EscapePressed,
ctx,
);
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
});
}
ctx.apply_self_handlers(handlers);
self.root_child_id = Some(root);
vec![root]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
self.root_child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(280.0, 56.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(self.at_role());
builder.set_live(self.at_live());
builder.inner_mut().set_live_atomic();
let name = self
.data
.announcement
.as_ref()
.map(|a| a.resolve_now())
.unwrap_or_else(|| self.data.title.resolve_now());
builder.set_name(name);
if let Some(body) = &self.data.body {
builder.set_description(body.resolve_now());
}
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
#[doc(hidden)]
pub fn _default_dismiss() -> std::time::Duration {
DEFAULT_TOAST_AUTO_DISMISS
}