use std::sync::Arc;
use blinc_animation::AnimationPreset;
use blinc_core::Color;
use blinc_layout::motion::motion_derived;
use blinc_layout::overlay_state::get_overlay_manager;
use blinc_layout::prelude::*;
use blinc_layout::widgets::overlay::{Corner, OverlayHandle, OverlayManagerExt};
use blinc_layout::InstanceKey;
use blinc_theme::{ColorToken, RadiusToken, ThemeState};
use super::button::{button, ButtonSize, ButtonVariant};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToastVariant {
#[default]
Default,
Success,
Warning,
Destructive,
}
impl ToastVariant {
fn accent_color(&self, theme: &ThemeState) -> Color {
match self {
ToastVariant::Default => theme.color(ColorToken::Primary),
ToastVariant::Success => theme.color(ColorToken::Success),
ToastVariant::Warning => theme.color(ColorToken::Warning),
ToastVariant::Destructive => theme.color(ColorToken::Error),
}
}
fn icon_svg(&self) -> Option<&'static str> {
match self {
ToastVariant::Default => None,
ToastVariant::Success => Some(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>"#,
),
ToastVariant::Warning => Some(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>"#,
),
ToastVariant::Destructive => Some(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" x2="9" y1="9" y2="15"/><line x1="9" x2="15" y1="9" y2="15"/></svg>"#,
),
}
}
}
pub type ToastBodyFn = Arc<dyn Fn() -> Div + Send + Sync>;
pub struct ToastBuilder {
title: Option<String>,
description: Option<String>,
variant: ToastVariant,
duration_ms: u32,
corner: Corner,
action_label: Option<String>,
action_callback: Option<Arc<dyn Fn() + Send + Sync>>,
show_close: bool,
body: Option<ToastBodyFn>,
custom_content: Option<ToastBodyFn>,
}
impl ToastBuilder {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: Some(title.into()),
description: None,
variant: ToastVariant::Default,
duration_ms: 5000, corner: Corner::BottomRight,
action_label: None,
action_callback: None,
show_close: true,
body: None,
custom_content: None,
}
}
pub fn custom<F>(content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
Self {
title: None,
description: None,
variant: ToastVariant::Default,
duration_ms: 5000,
corner: Corner::BottomRight,
action_label: None,
action_callback: None,
show_close: false,
body: None,
custom_content: Some(Arc::new(content)),
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn body<F>(mut self, content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
self.body = Some(Arc::new(content));
self
}
pub fn variant(mut self, variant: ToastVariant) -> Self {
self.variant = variant;
self
}
pub fn duration_ms(mut self, ms: u32) -> Self {
self.duration_ms = ms;
self
}
pub fn persistent(mut self) -> Self {
self.duration_ms = 0; self
}
pub fn corner(mut self, corner: Corner) -> Self {
self.corner = corner;
self
}
pub fn action<F>(mut self, label: impl Into<String>, callback: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.action_label = Some(label.into());
self.action_callback = Some(Arc::new(callback));
self
}
pub fn show_close(mut self, show: bool) -> Self {
self.show_close = show;
self
}
#[track_caller]
pub fn show(self) -> OverlayHandle {
let theme = ThemeState::get();
let bg = theme.color(ColorToken::Surface);
let border = theme.color(ColorToken::Border);
let text_primary = theme.color(ColorToken::TextPrimary);
let text_secondary = theme.color(ColorToken::TextSecondary);
let radius = theme.radius(RadiusToken::Lg);
let title = self.title;
let description = self.description;
let variant = self.variant;
let accent_color = variant.accent_color(theme);
let icon_svg = variant.icon_svg().map(|s| s.to_string());
let action_label = self.action_label;
let action_callback = self.action_callback;
let show_close = self.show_close;
let corner = self.corner;
let duration_ms = self.duration_ms;
let body = self.body;
let custom_content = self.custom_content;
let mgr = get_overlay_manager();
let key = InstanceKey::new("toast");
let motion_key_str = format!("toast_{}", key.get());
let motion_key_with_child = format!("{}:child:0", motion_key_str);
let mut toast_builder = mgr
.toast()
.corner(corner)
.motion_key(&motion_key_with_child)
.content(move || {
build_toast_content(
&title,
&description,
&icon_svg,
accent_color,
variant,
bg,
border,
text_primary,
text_secondary,
radius,
&action_label,
&action_callback,
show_close,
&body,
&custom_content,
corner,
&motion_key_str,
)
});
if duration_ms > 0 {
toast_builder = toast_builder.duration_ms(duration_ms);
}
toast_builder.show()
}
}
fn get_enter_animation(corner: Corner) -> blinc_animation::MultiKeyframeAnimation {
const SLIDE_DISTANCE: f32 = 200.0;
match corner {
Corner::TopLeft | Corner::BottomLeft => AnimationPreset::slide_in_left(200, SLIDE_DISTANCE),
Corner::TopRight | Corner::BottomRight => {
AnimationPreset::slide_in_right(200, SLIDE_DISTANCE)
}
}
}
fn get_exit_animation(corner: Corner) -> blinc_animation::MultiKeyframeAnimation {
const SLIDE_DISTANCE: f32 = 200.0;
match corner {
Corner::TopLeft | Corner::BottomLeft => {
AnimationPreset::slide_out_left(150, SLIDE_DISTANCE)
}
Corner::TopRight | Corner::BottomRight => {
AnimationPreset::slide_out_right(150, SLIDE_DISTANCE)
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_toast_content(
title: &Option<String>,
description: &Option<String>,
icon_svg: &Option<String>,
accent_color: Color,
variant: ToastVariant,
bg: Color,
border: Color,
text_primary: Color,
text_secondary: Color,
radius: f32,
action_label: &Option<String>,
action_callback: &Option<Arc<dyn Fn() + Send + Sync>>,
show_close: bool,
body: &Option<ToastBodyFn>,
custom_content: &Option<ToastBodyFn>,
corner: Corner,
motion_key: &str,
) -> Div {
let theme = ThemeState::get();
let enter_anim = get_enter_animation(corner);
let exit_anim = get_exit_animation(corner);
if let Some(ref custom_fn) = custom_content {
let toast = custom_fn()
.class("cn-toast")
.w(360.0)
.bg(bg)
.border(1.0, border)
.rounded(radius)
.shadow_lg();
return div().child(
motion_derived(motion_key)
.enter_animation(enter_anim)
.exit_animation(exit_anim)
.child(toast),
);
}
let has_accent = icon_svg.is_some();
let mut toast = div()
.class("cn-toast")
.w(360.0)
.bg(bg)
.border(1.0, border)
.rounded(radius)
.shadow_lg();
match variant {
ToastVariant::Success => toast = toast.class("cn-toast--success"),
ToastVariant::Warning => toast = toast.class("cn-toast--warning"),
ToastVariant::Destructive => toast = toast.class("cn-toast--error"),
ToastVariant::Default => toast = toast.class("cn-toast--info"),
}
if has_accent {
toast = toast.border_left(4.0, accent_color);
}
let mut inner = div().w_full().flex_row().items_start().gap_3().p_4();
if let Some(ref svg_str) = icon_svg {
inner = inner.child(
div()
.flex_shrink_0()
.child(svg(svg_str).size(20.0, 20.0).color(accent_color)),
);
}
let mut content = div().flex_1().flex_col().gap_1();
if let Some(ref title_text) = title {
content = content.child(
text(title_text)
.size(theme.typography().text_sm)
.color(text_primary)
.medium(),
);
}
if let Some(ref body_fn) = body {
content = content.child(body_fn());
} else if let Some(ref desc) = description {
content = content.child(
text(desc)
.size(theme.typography().text_sm)
.color(text_secondary),
);
}
inner = inner.child(content);
if let Some(ref label) = action_label {
let callback = action_callback.clone();
inner = inner.child(
button(label)
.variant(ButtonVariant::Outline)
.size(ButtonSize::Small)
.on_click(move |_| {
if let Some(ref cb) = callback {
cb();
}
get_overlay_manager().close_top();
}),
);
}
if show_close {
let close_icon = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" x2="6" y1="6" y2="18"/><line x1="6" x2="18" y1="6" y2="18"/></svg>"#;
inner = inner.child(
div()
.flex_shrink_0()
.w(24.0)
.h(24.0)
.items_center()
.rounded(theme.radius(RadiusToken::Sm))
.cursor_pointer()
.on_click(|_| {
get_overlay_manager().close_top();
})
.child(svg(close_icon).size(16.0, 16.0).color(text_secondary)),
);
}
toast = toast.child(inner);
div().child(
motion_derived(motion_key)
.enter_animation(enter_anim)
.exit_animation(exit_anim)
.child(toast),
)
}
pub fn toast(title: impl Into<String>) -> ToastBuilder {
ToastBuilder::new(title)
}
pub fn toast_success(title: impl Into<String>) -> ToastBuilder {
ToastBuilder::new(title).variant(ToastVariant::Success)
}
pub fn toast_warning(title: impl Into<String>) -> ToastBuilder {
ToastBuilder::new(title).variant(ToastVariant::Warning)
}
pub fn toast_error(title: impl Into<String>) -> ToastBuilder {
ToastBuilder::new(title).variant(ToastVariant::Destructive)
}
pub fn toast_custom<F>(content: F) -> ToastBuilder
where
F: Fn() -> Div + Send + Sync + 'static,
{
ToastBuilder::custom(content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_toast_builder() {
let toast = ToastBuilder::new("Test");
assert_eq!(toast.title, Some("Test".to_string()));
assert_eq!(toast.duration_ms, 5000);
}
#[test]
fn test_toast_with_description() {
let toast = ToastBuilder::new("Title").description("Description");
assert_eq!(toast.description, Some("Description".to_string()));
}
#[test]
fn test_toast_persistent() {
let toast = ToastBuilder::new("Persistent").persistent();
assert_eq!(toast.duration_ms, 0);
}
#[test]
fn test_toast_variants() {
assert!(ToastVariant::Default.icon_svg().is_none());
assert!(ToastVariant::Success.icon_svg().is_some());
assert!(ToastVariant::Warning.icon_svg().is_some());
assert!(ToastVariant::Destructive.icon_svg().is_some());
}
}