Skip to main content

azul_layout/widgets/
toast.rs

1//! Toast / snackbar widget — a transient notification banner. A near-clone of
2//! [`crate::widgets::alert::Alert`] (a coloured message box with a "x" dismiss
3//! affordance and a `visible` state) that, instead of sitting inline, floats as
4//! an overlay pinned to a corner of its positioned parent
5//! (`position: absolute; bottom; right`).
6//!
7//! Like [`crate::widgets::alert::Alert`] / [`crate::widgets::check_box::CheckBox`]
8//! it is stateful: it carries a [`ToastStateWrapper`] (`{ visible } + on_dismiss`)
9//! in a [`RefAny`] attached to the "x" close button. Clicking "x" flips `visible`
10//! to `false`, invokes the optional user `on_dismiss`, and hides the whole toast
11//! by setting `display: none` on the container via `set_css_property` (mirroring
12//! alert's / check_box's live restyle).
13//!
14//! TODO2 — **auto-dismiss is intentionally NOT implemented (be honest, don't fake
15//! it).** A real toast disappears on its own after N seconds. That requires a
16//! host-driven `Timer`/`Update` loop that re-enters the event loop on a clock
17//! tick and flips `visible` to `false` — a widget handler cannot *start* such a
18//! timer (it only runs in response to an input event, with no access to schedule
19//! a future wakeup). This is the same limitation the spinner hit with CSS
20//! animation: there is no widget-local timer. So this widget ships a **manually**
21//! dismissable toast (the "x"); a host that wants auto-timeout must register a
22//! `Timer` itself and call `set_css_property(display: none)` (or rebuild without
23//! the toast) when it fires.
24//!
25//! TODO2 — covering sibling widgets relies on paint order (being a later sibling)
26//! because there is no real stacking-context / z-index, and a drop `box-shadow`
27//! elevation is omitted (it needs a runtime-heap shadow value — see
28//! `progressbar.rs`); the border + radius over the page convey the floating card.
29//! The `display:none` relayout itself is not GUI-verified in this build.
30//!
31//! Key types: [`Toast`], [`ToastKind`], [`ToastState`], [`ToastOnDismiss`].
32
33use azul_core::{
34    callbacks::{CoreCallbackData, Update},
35    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
36    refany::RefAny,
37};
38use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
39use azul_css::{
40    props::{
41        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, StyleFontSize},
42        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutFlexGrow, LayoutPosition, LayoutInsetBottom, LayoutRight, LayoutMaxWidth, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutMarginLeft},
43        property::{CssProperty, *},
44        style::{StyleBackgroundContentVec, StyleBackgroundContent, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextColor, StyleTextAlign, StyleCursor, StyleUserSelect},
45    },
46    impl_option_inner, AzString,
47};
48
49use crate::callbacks::{Callback, CallbackInfo};
50
51static TOAST_CONTAINER_CLASS: &[IdOrClass] =
52    &[Class(AzString::from_const_str("__azul-native-toast"))];
53static TOAST_MESSAGE_CLASS: &[IdOrClass] =
54    &[Class(AzString::from_const_str("__azul-native-toast-message"))];
55static TOAST_CLOSE_CLASS: &[IdOrClass] =
56    &[Class(AzString::from_const_str("__azul-native-toast-close"))];
57
58const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
59const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
60const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
61    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
62
63/// Distance (logical px) of the toast from the bottom / right edges of its parent.
64const TOAST_INSET: isize = 24;
65/// Maximum width (logical px) of the toast card.
66const TOAST_MAX_WIDTH: isize = 360;
67
68/// Callback function type invoked when a toast's "x" close button is clicked.
69pub type ToastOnDismissCallbackType = extern "C" fn(RefAny, CallbackInfo, ToastState) -> Update;
70impl_widget_callback!(
71    ToastOnDismiss,
72    OptionToastOnDismiss,
73    ToastOnDismissCallback,
74    ToastOnDismissCallbackType
75);
76
77azul_core::impl_managed_callback! {
78    wrapper:        ToastOnDismissCallback,
79    info_ty:        CallbackInfo,
80    return_ty:      Update,
81    default_ret:    Update::DoNothing,
82    invoker_static: TOAST_ON_DISMISS_INVOKER,
83    invoker_ty:     AzToastOnDismissCallbackInvoker,
84    thunk_fn:       az_toast_on_dismiss_callback_thunk,
85    setter_fn:      AzApp_setToastOnDismissCallbackInvoker,
86    from_handle_fn: AzToastOnDismissCallback_createFromHostHandle,
87    extra_args:     [ state: ToastState ],
88}
89
90/// The semantic colour variant of a [`Toast`] (Bootstrap alert palette, mirroring
91/// [`crate::widgets::alert::AlertKind`]).
92#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
93#[repr(C)]
94pub enum ToastKind {
95    /// Blue informational toast — the default.
96    #[default]
97    Info,
98    /// Green success toast.
99    Success,
100    /// Yellow warning toast.
101    Warning,
102    /// Red danger/error toast.
103    Danger,
104}
105
106impl ToastKind {
107    /// Returns the `(background, border, text)` colours for this toast kind.
108    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
109    const fn colors(&self) -> (ColorU, ColorU, ColorU) {
110        match self {
111            Self::Info => (
112                ColorU { r: 207, g: 244, b: 252, a: 255 }, // #cff4fc
113                ColorU { r: 182, g: 239, b: 251, a: 255 }, // #b6effb
114                ColorU { r: 5, g: 81, b: 96, a: 255 },     // #055160
115            ),
116            Self::Success => (
117                ColorU { r: 209, g: 231, b: 221, a: 255 }, // #d1e7dd
118                ColorU { r: 186, g: 219, b: 204, a: 255 }, // #badbcc
119                ColorU { r: 15, g: 81, b: 50, a: 255 },    // #0f5132
120            ),
121            Self::Warning => (
122                ColorU { r: 255, g: 243, b: 205, a: 255 }, // #fff3cd
123                ColorU { r: 255, g: 236, b: 181, a: 255 }, // #ffecb5
124                ColorU { r: 102, g: 77, b: 3, a: 255 },    // #664d03
125            ),
126            Self::Danger => (
127                ColorU { r: 248, g: 215, b: 218, a: 255 }, // #f8d7da
128                ColorU { r: 245, g: 194, b: 199, a: 255 }, // #f5c2c7
129                ColorU { r: 132, g: 32, b: 41, a: 255 },   // #842029
130            ),
131        }
132    }
133
134    /// CSS class name for this toast kind (mirrors `AlertKind::class_name`).
135    #[must_use] pub const fn class_name(&self) -> &'static str {
136        match self {
137            Self::Info => "__azul-toast-info",
138            Self::Success => "__azul-toast-success",
139            Self::Warning => "__azul-toast-warning",
140            Self::Danger => "__azul-toast-danger",
141        }
142    }
143}
144
145/// A transient, floating notification banner with a "x" dismiss button.
146#[derive(Debug, Clone, PartialEq, Eq)]
147#[repr(C)]
148pub struct Toast {
149    /// Runtime state (`visible`) plus the optional dismiss callback.
150    pub toast_state: ToastStateWrapper,
151    /// The message text shown inside the toast.
152    pub message: AzString,
153    /// The colour variant.
154    pub kind: ToastKind,
155    /// Whether to render the "x" close button (default `true` — the only way to
156    /// dismiss; see the module-level auto-dismiss TODO2).
157    pub dismissible: bool,
158    /// The computed inline style for the (absolutely-positioned) container.
159    pub container_style: CssPropertyWithConditionsVec,
160}
161
162#[derive(Debug, Default, Clone, PartialEq, Eq)]
163#[repr(C)]
164pub struct ToastStateWrapper {
165    /// Whether the toast is currently visible.
166    pub inner: ToastState,
167    /// Optional: function to call when the toast is dismissed.
168    pub on_dismiss: OptionToastOnDismiss,
169}
170
171/// The visible/hidden state of a [`Toast`].
172#[derive(Debug, Copy, Clone, PartialEq, Eq)]
173#[repr(C)]
174pub struct ToastState {
175    /// `true` (default) = shown, `false` = dismissed/hidden.
176    pub visible: bool,
177}
178
179impl Default for ToastState {
180    fn default() -> Self {
181        Self { visible: true }
182    }
183}
184
185/// Builds the container style for a given [`ToastKind`]. Mirrors
186/// `alert::build_alert_style` but pins the box to the bottom-right corner of its
187/// positioned parent (`position: absolute`) and caps its width instead of
188/// stretching to fill a flex column.
189fn build_toast_style(kind: ToastKind) -> CssPropertyWithConditionsVec {
190    let (bg, border, text) = kind.colors();
191    let bg_vec =
192        StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(bg)]);
193    CssPropertyWithConditionsVec::from_vec(alloc::vec![
194        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
195        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
196            LayoutFlexDirection::Row,
197        )),
198        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Start)),
199        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
200            0,
201        ))),
202        // Float pinned to the bottom-right corner of the positioned parent.
203        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
204        CssPropertyWithConditions::simple(CssProperty::const_bottom(LayoutInsetBottom::const_px(
205            TOAST_INSET,
206        ))),
207        CssPropertyWithConditions::simple(CssProperty::const_right(LayoutRight::const_px(
208            TOAST_INSET,
209        ))),
210        // Cap the width so the toast hugs its content rather than spanning the page.
211        CssPropertyWithConditions::simple(CssProperty::const_max_width(LayoutMaxWidth::const_px(
212            TOAST_MAX_WIDTH,
213        ))),
214        // padding: 12px
215        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
216            12,
217        ))),
218        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
219            LayoutPaddingBottom::const_px(12),
220        )),
221        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
222            LayoutPaddingLeft::const_px(12),
223        )),
224        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
225            LayoutPaddingRight::const_px(12),
226        )),
227        // border: 1px solid <border>
228        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
229            LayoutBorderTopWidth::const_px(1),
230        )),
231        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
232            LayoutBorderBottomWidth::const_px(1),
233        )),
234        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
235            LayoutBorderLeftWidth::const_px(1),
236        )),
237        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
238            LayoutBorderRightWidth::const_px(1),
239        )),
240        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
241            inner: BorderStyle::Solid,
242        })),
243        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
244            StyleBorderBottomStyle {
245                inner: BorderStyle::Solid,
246            },
247        )),
248        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(
249            StyleBorderLeftStyle {
250                inner: BorderStyle::Solid,
251            },
252        )),
253        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
254            StyleBorderRightStyle {
255                inner: BorderStyle::Solid,
256            },
257        )),
258        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
259            inner: border,
260        })),
261        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
262            StyleBorderBottomColor { inner: border },
263        )),
264        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(
265            StyleBorderLeftColor { inner: border },
266        )),
267        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
268            StyleBorderRightColor { inner: border },
269        )),
270        // border-radius: 6px
271        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
272            StyleBorderTopLeftRadius::const_px(6),
273        )),
274        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
275            StyleBorderTopRightRadius::const_px(6),
276        )),
277        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
278            StyleBorderBottomLeftRadius::const_px(6),
279        )),
280        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
281            StyleBorderBottomRightRadius::const_px(6),
282        )),
283        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
284        CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
285        // Text colour is inherited by the message + close children.
286        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
287            inner: text,
288        })),
289        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
290    ])
291}
292
293/// Message-text style: takes the remaining horizontal space, left-aligned.
294static TOAST_MESSAGE_STYLE: &[CssPropertyWithConditions] = &[
295    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
296    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
297];
298
299/// Close-button ("x") style: a small pointer-cursor box on the right.
300static TOAST_CLOSE_STYLE: &[CssPropertyWithConditions] = &[
301    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
302    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
303    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
304    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
305    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(
306        12,
307    ))),
308];
309
310impl Toast {
311    /// Creates a new informational (blue) toast with the given message (visible,
312    /// with a "x" close button).
313    #[inline]
314    #[must_use] pub fn create(message: AzString) -> Self {
315        Self::with_kind(message, ToastKind::Info)
316    }
317
318    /// Creates a new toast with the given message and colour variant.
319    #[inline]
320    #[must_use] pub fn with_kind(message: AzString, kind: ToastKind) -> Self {
321        Self {
322            toast_state: ToastStateWrapper::default(),
323            message,
324            kind,
325            dismissible: true,
326            container_style: build_toast_style(kind),
327        }
328    }
329
330    /// Sets the colour variant, recomputing the container style.
331    #[inline]
332    pub fn set_kind(&mut self, kind: ToastKind) {
333        self.kind = kind;
334        self.container_style = build_toast_style(kind);
335    }
336
337    /// Builder-style setter for the colour variant.
338    #[inline]
339    #[must_use] pub fn with_toast_kind(mut self, kind: ToastKind) -> Self {
340        self.set_kind(kind);
341        self
342    }
343
344    /// Sets whether the toast shows a "x" close button.
345    #[inline]
346    pub const fn set_dismissible(&mut self, dismissible: bool) {
347        self.dismissible = dismissible;
348    }
349
350    /// Builder-style setter for the dismissible flag.
351    #[inline]
352    #[must_use] pub const fn with_dismissible(mut self, dismissible: bool) -> Self {
353        self.set_dismissible(dismissible);
354        self
355    }
356
357    /// Sets the dismiss callback. Implies `dismissible = true` so the close
358    /// button is rendered.
359    #[inline]
360    pub fn set_on_dismiss<C: Into<ToastOnDismissCallback>>(&mut self, data: RefAny, on_dismiss: C) {
361        self.dismissible = true;
362        self.toast_state.on_dismiss = Some(ToastOnDismiss {
363            callback: on_dismiss.into(),
364            refany: data,
365        })
366        .into();
367    }
368
369    /// Builder-style setter for the dismiss callback (implies dismissible).
370    #[inline]
371    #[must_use] pub fn with_on_dismiss<C: Into<ToastOnDismissCallback>>(
372        mut self,
373        data: RefAny,
374        on_dismiss: C,
375    ) -> Self {
376        self.set_on_dismiss(data, on_dismiss);
377        self
378    }
379
380    /// Replaces `self` with a default (empty info) toast and returns the original.
381    #[inline]
382    #[must_use] pub fn swap_with_default(&mut self) -> Self {
383        let mut s = Self::create(AzString::from_const_str(""));
384        core::mem::swap(&mut s, self);
385        s
386    }
387
388    /// Converts this toast into a DOM subtree with the `__azul-native-toast` class.
389    #[inline]
390    #[must_use] pub fn dom(self) -> Dom {
391        use azul_core::{
392            callbacks::CoreCallback,
393            dom::{EventFilter, HoverEventFilter},
394            refany::OptionRefAny,
395        };
396
397        let message = Dom::create_text(self.message)
398            .with_ids_and_classes(IdOrClassVec::from_const_slice(TOAST_MESSAGE_CLASS))
399            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(TOAST_MESSAGE_STYLE));
400
401        let mut children = alloc::vec![message];
402
403        if self.dismissible {
404            let close = Dom::create_text(AzString::from_const_str("\u{00D7}"))
405                .with_ids_and_classes(IdOrClassVec::from_const_slice(TOAST_CLOSE_CLASS))
406                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(TOAST_CLOSE_STYLE))
407                .with_tab_index(TabIndex::Auto)
408                .with_callbacks(
409                    alloc::vec![CoreCallbackData {
410                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
411                        callback: CoreCallback {
412                            cb: default_on_toast_dismiss as usize,
413                            ctx: OptionRefAny::None,
414                        },
415                        refany: RefAny::new(self.toast_state),
416                    }]
417                    .into(),
418                );
419            children.push(close);
420        }
421
422        Dom::create_div()
423            .with_ids_and_classes(IdOrClassVec::from_const_slice(TOAST_CONTAINER_CLASS))
424            .with_css_props(self.container_style)
425            .with_children(children.into())
426    }
427}
428
429impl Default for Toast {
430    fn default() -> Self {
431        Self::create(AzString::from_const_str(""))
432    }
433}
434
435/// Close-button click handler. The hit node is the close button (the
436/// callback-bearing node, per `currentTarget` semantics — see `alert`); its
437/// parent is the toast container. Flips `visible` to `false`, invokes the
438/// optional user callback, then hides the whole toast via `display: none`.
439extern "C" fn default_on_toast_dismiss(mut data: RefAny, mut info: CallbackInfo) -> Update {
440    let close_node = info.get_hit_node();
441    let Some(container) = info.get_parent(close_node) else {
442        return Update::DoNothing;
443    };
444
445    let result = {
446        let Some(mut toast) = data.downcast_mut::<ToastStateWrapper>() else {
447            return Update::DoNothing;
448        };
449        toast.inner.visible = false;
450        let inner = toast.inner;
451        let toast = &mut *toast;
452        match toast.on_dismiss.as_mut() {
453            Some(ToastOnDismiss { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
454            None => Update::DoNothing,
455        }
456    };
457
458    // TODO2: hides the toast by toggling `display: none` via set_css_property.
459    // This follows the proven live-restyle pattern of alert/check_box (which
460    // toggle display/opacity/background); the display:none relayout itself is not
461    // GUI-verified in this build. (Auto-timeout dismissal is a host-driven Timer —
462    // see the module-level TODO2 — and is intentionally not attempted here.)
463    info.set_css_property(container, CssProperty::const_display(LayoutDisplay::None));
464
465    result
466}
467
468impl From<Toast> for Dom {
469    fn from(t: Toast) -> Self {
470        t.dom()
471    }
472}