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}
473
474#[cfg(test)]
475// `assertions_on_constants`: these are deliberate invariant guards over sibling
476// `const`s in this module. They are const-foldable *today*, which is exactly the
477// point — they must go red the moment someone edits one of those constants into an
478// inconsistent value. Deleting them (clippy's suggestion) would delete the check.
479#[allow(clippy::assertions_on_constants)]
480mod autotest_generated {
481    use std::{
482        collections::{BTreeMap, HashMap},
483        sync::{Arc, Mutex},
484    };
485
486    use azul_core::{
487        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
488        geom::{LogicalRect, OptionLogicalPosition},
489        gl::OptionGlContextPtr,
490        hit_test::ScrollPosition,
491        refany::OptionRefAny,
492        resources::RendererResources,
493        styled_dom::{NodeHierarchyItemId, StyledDom},
494        window::{MonitorVec, RawWindowHandle},
495    };
496    use azul_css::{
497        props::basic::{length::SizeMetric, pixel::PixelValue},
498        system::SystemStyle,
499    };
500    use rust_fontconfig::FcFontCache;
501
502    use super::*;
503    #[cfg(feature = "icu")]
504    use crate::icu::IcuLocalizerHandle;
505    use crate::{
506        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
507        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
508        window::{DomLayoutResult, LayoutWindow},
509        window_state::FullWindowState,
510    };
511
512    // ------------------------------------------------------------------
513    // Helpers
514    // ------------------------------------------------------------------
515
516    const ALL_KINDS: [ToastKind; 4] = [
517        ToastKind::Info,
518        ToastKind::Success,
519        ToastKind::Warning,
520        ToastKind::Danger,
521    ];
522
523    /// The text of a `NodeType::Text` node (`None` for any other node type).
524    fn text_of(node: &Dom) -> Option<&str> {
525        match node.root.get_node_type() {
526            NodeType::Text(s) => Some(s.as_ref().as_str()),
527            _ => None,
528        }
529    }
530
531    /// The inline (static) CSS properties actually attached to a DOM node.
532    fn inline_props(node: &Dom) -> Vec<CssProperty> {
533        node.root
534            .style
535            .iter_inline_properties()
536            .map(|(p, _)| p.clone())
537            .collect()
538    }
539
540    /// The `background-color` of a style vec (first background layer only).
541    fn background_color(style: &CssPropertyWithConditionsVec) -> Option<ColorU> {
542        style.as_ref().iter().find_map(|p| match &p.property {
543            CssProperty::BackgroundContent(v) => match v.get_property()?.as_ref().first()? {
544                StyleBackgroundContent::Color(c) => Some(*c),
545                _ => None,
546            },
547            _ => None,
548        })
549    }
550
551    /// Every `border-*-color` in a style vec, in declaration order.
552    fn border_colors(style: &CssPropertyWithConditionsVec) -> Vec<ColorU> {
553        style
554            .as_ref()
555            .iter()
556            .filter_map(|p| match &p.property {
557                CssProperty::BorderTopColor(v) => v.get_property().map(|c| c.inner),
558                CssProperty::BorderBottomColor(v) => v.get_property().map(|c| c.inner),
559                CssProperty::BorderLeftColor(v) => v.get_property().map(|c| c.inner),
560                CssProperty::BorderRightColor(v) => v.get_property().map(|c| c.inner),
561                _ => None,
562            })
563            .collect()
564    }
565
566    /// The `color` (text colour) of a style vec.
567    fn text_color(style: &CssPropertyWithConditionsVec) -> Option<ColorU> {
568        style.as_ref().iter().find_map(|p| match &p.property {
569            CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
570            _ => None,
571        })
572    }
573
574    /// The declared `position` of a style vec.
575    fn position_of(style: &CssPropertyWithConditionsVec) -> Option<LayoutPosition> {
576        style.as_ref().iter().find_map(|p| match &p.property {
577            CssProperty::Position(v) => v.get_property().copied(),
578            _ => None,
579        })
580    }
581
582    /// The `bottom` offset of a style vec, as a raw `PixelValue`.
583    fn bottom_px(style: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
584        style.as_ref().iter().find_map(|p| match &p.property {
585            CssProperty::Bottom(v) => v.get_property().map(|b| b.inner),
586            _ => None,
587        })
588    }
589
590    /// The `right` offset of a style vec, as a raw `PixelValue`.
591    fn right_px(style: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
592        style.as_ref().iter().find_map(|p| match &p.property {
593            CssProperty::Right(v) => v.get_property().map(|r| r.inner),
594            _ => None,
595        })
596    }
597
598    /// The `max-width` of a style vec, as a raw `PixelValue`.
599    fn max_width_px(style: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
600        style.as_ref().iter().find_map(|p| match &p.property {
601            CssProperty::MaxWidth(v) => v.get_property().map(|w| w.inner),
602            _ => None,
603        })
604    }
605
606    /// The *kind* of every declared property, in order (ignores the values).
607    fn property_types(
608        style: &CssPropertyWithConditionsVec,
609    ) -> Vec<core::mem::Discriminant<CssProperty>> {
610        style
611            .as_ref()
612            .iter()
613            .map(|p| core::mem::discriminant(&p.property))
614            .collect()
615    }
616
617    /// A `RefAny` payload recording every `ToastState` a user `on_dismiss` sees.
618    struct DismissLog {
619        calls: Vec<bool>,
620    }
621
622    extern "C" fn record_dismiss(mut data: RefAny, _: CallbackInfo, state: ToastState) -> Update {
623        if let Some(mut log) = data.downcast_mut::<DismissLog>() {
624            log.calls.push(state.visible);
625        }
626        Update::RefreshDom
627    }
628
629    extern "C" fn dismiss_do_nothing(_: RefAny, _: CallbackInfo, _: ToastState) -> Update {
630        Update::DoNothing
631    }
632
633    fn dismiss_cb(f: ToastOnDismissCallbackType) -> ToastOnDismissCallback {
634        f.into()
635    }
636
637    /// `visible` of a `ToastStateWrapper` payload.
638    fn wrapper_visible(data: &mut RefAny) -> bool {
639        data.downcast_ref::<ToastStateWrapper>()
640            .expect("payload must still be a ToastStateWrapper")
641            .inner
642            .visible
643    }
644
645    /// The `visible` flags recorded by a `DismissLog` payload.
646    fn log_calls(data: &mut RefAny) -> Vec<bool> {
647        data.downcast_ref::<DismissLog>()
648            .expect("payload must still be a DismissLog")
649            .calls
650            .clone()
651    }
652
653    /// A `DomLayoutResult` with an *empty* layout tree: the dismiss handler only
654    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
655    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
656        DomLayoutResult {
657            styled_dom,
658            layout_tree: LayoutTree {
659                nodes: Vec::new(),
660                warm: Vec::new(),
661                cold: Vec::new(),
662                root: 0,
663                dom_to_layout: BTreeMap::new(),
664                children_arena: Vec::new(),
665                children_offsets: Vec::new(),
666                subtree_needs_intrinsic: Vec::new(),
667            },
668            calculated_positions: Vec::new(),
669            viewport: LogicalRect::zero(),
670            display_list: DisplayList::default(),
671            scroll_ids: HashMap::new(),
672            scroll_id_to_node_id: HashMap::new(),
673        }
674    }
675
676    /// The flattened DOM of a default toast: `container(0)`, `message(1)`,
677    /// `close(2)` — i.e. exactly the hierarchy `default_on_toast_dismiss` walks
678    /// (hit node -> parent).
679    fn dismissible_styled_dom() -> StyledDom {
680        let toast = Toast::create(AzString::from("msg"));
681        assert!(
682            toast.dismissible,
683            "a fresh toast must already carry a close button"
684        );
685        let styled = StyledDom::create_from_dom(toast.dom());
686        assert_eq!(
687            styled.node_hierarchy.as_ref().len(),
688            3,
689            "fixture must flatten to exactly container/message/close"
690        );
691        styled
692    }
693
694    /// Invokes `default_on_toast_dismiss` against a `LayoutWindow` holding
695    /// `styled` (or nothing at all, when `styled` is `None`), with `hit` as the
696    /// hit node. Returns the `Update` plus every recorded `CallbackChange`.
697    fn run_dismiss(
698        styled: Option<StyledDom>,
699        hit: usize,
700        data: RefAny,
701    ) -> (Update, Vec<CallbackChange>) {
702        let mut layout_window =
703            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
704        if let Some(sd) = styled {
705            layout_window
706                .layout_results
707                .insert(DomId::ROOT_ID, layout_result(sd));
708        }
709
710        let renderer_resources = RendererResources::default();
711        let previous_window_state: Option<FullWindowState> = None;
712        let current_window_state = FullWindowState::default();
713        let gl_context = OptionGlContextPtr::None;
714        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
715            BTreeMap::new();
716        let window_handle = RawWindowHandle::Unsupported;
717        let system_callbacks = ExternalSystemCallbacks::rust_internal();
718
719        let ref_data = CallbackInfoRefData {
720            layout_window: &layout_window,
721            renderer_resources: &renderer_resources,
722            previous_window_state: &previous_window_state,
723            current_window_state: &current_window_state,
724            gl_context: &gl_context,
725            current_scroll_manager: &scroll_states,
726            current_window_handle: &window_handle,
727            system_callbacks: &system_callbacks,
728            system_style: Arc::new(SystemStyle::default()),
729            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
730            #[cfg(feature = "icu")]
731            icu_localizer: IcuLocalizerHandle::default(),
732            ctx: OptionRefAny::None,
733        };
734
735        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
736
737        let info = CallbackInfo::new(
738            &ref_data,
739            &changes,
740            DomNodeId {
741                dom: DomId::ROOT_ID,
742                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
743            },
744            OptionLogicalPosition::None,
745            OptionLogicalPosition::None,
746        );
747
748        let update = default_on_toast_dismiss(data, info);
749        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
750        (update, recorded)
751    }
752
753    /// Every `display` write recorded in the change log, as `(node index, display)`.
754    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
755        let mut out = Vec::new();
756        for change in changes {
757            if let CallbackChange::ChangeNodeCssProperties {
758                node_id, properties, ..
759            } = change
760            {
761                for p in properties.as_ref() {
762                    if let CssProperty::Display(v) = p {
763                        if let Some(d) = v.get_property() {
764                            out.push((node_id.index(), *d));
765                        }
766                    }
767                }
768            }
769        }
770        out
771    }
772
773    // ------------------------------------------------------------------
774    // ToastKind::colors  (getter)
775    // ------------------------------------------------------------------
776
777    #[test]
778    fn kind_colors_are_the_documented_bootstrap_palette() {
779        let expect = |(r, g, b): (u8, u8, u8)| ColorU { r, g, b, a: 255 };
780
781        assert_eq!(
782            ToastKind::Info.colors(),
783            (
784                expect((207, 244, 252)), // #cff4fc
785                expect((182, 239, 251)), // #b6effb
786                expect((5, 81, 96)),     // #055160
787            )
788        );
789        assert_eq!(
790            ToastKind::Success.colors(),
791            (
792                expect((209, 231, 221)), // #d1e7dd
793                expect((186, 219, 204)), // #badbcc
794                expect((15, 81, 50)),    // #0f5132
795            )
796        );
797        assert_eq!(
798            ToastKind::Warning.colors(),
799            (
800                expect((255, 243, 205)), // #fff3cd
801                expect((255, 236, 181)), // #ffecb5
802                expect((102, 77, 3)),    // #664d03
803            )
804        );
805        assert_eq!(
806            ToastKind::Danger.colors(),
807            (
808                expect((248, 215, 218)), // #f8d7da
809                expect((245, 194, 199)), // #f5c2c7
810                expect((132, 32, 41)),   // #842029
811            )
812        );
813    }
814
815    #[test]
816    fn kind_colors_are_fully_opaque_and_pairwise_distinct() {
817        for kind in ALL_KINDS {
818            let (bg, border, text) = kind.colors();
819            for (name, c) in [("bg", bg), ("border", border), ("text", text)] {
820                assert_eq!(c.a, 255, "{kind:?}.{name} must be fully opaque");
821            }
822            // a floating card is only legible if bg != text
823            assert_ne!(bg, text, "{kind:?}: background must differ from text");
824            // ... and only visible against the page if bg != border
825            assert_ne!(bg, border, "{kind:?}: the border must be visible on the card");
826        }
827
828        for (i, a) in ALL_KINDS.iter().enumerate() {
829            for b in &ALL_KINDS[i + 1..] {
830                assert_ne!(
831                    a.colors(),
832                    b.colors(),
833                    "{a:?} and {b:?} must be visually distinguishable"
834                );
835            }
836        }
837    }
838
839    #[test]
840    fn kind_colors_default_is_info_and_the_call_is_pure() {
841        assert_eq!(ToastKind::default(), ToastKind::Info);
842        assert_eq!(ToastKind::default().colors(), ToastKind::Info.colors());
843
844        // repeated calls on the same (Copy) receiver must be stable
845        let k = ToastKind::Danger;
846        assert_eq!(k.colors(), k.colors());
847        assert_eq!(k.colors(), k.colors());
848    }
849
850    #[test]
851    fn kind_colors_is_const_evaluable() {
852        const INFO: (ColorU, ColorU, ColorU) = ToastKind::Info.colors();
853        assert_eq!(
854            INFO.0,
855            ColorU {
856                r: 207,
857                g: 244,
858                b: 252,
859                a: 255
860            }
861        );
862    }
863
864    // ------------------------------------------------------------------
865    // ToastKind::class_name  (getter)
866    // ------------------------------------------------------------------
867
868    #[test]
869    fn class_name_exact_values_and_shape() {
870        assert_eq!(ToastKind::Info.class_name(), "__azul-toast-info");
871        assert_eq!(ToastKind::Success.class_name(), "__azul-toast-success");
872        assert_eq!(ToastKind::Warning.class_name(), "__azul-toast-warning");
873        assert_eq!(ToastKind::Danger.class_name(), "__azul-toast-danger");
874
875        for kind in ALL_KINDS {
876            let name = kind.class_name();
877            assert!(
878                name.starts_with("__azul-toast-"),
879                "{kind:?} -> {name:?} must keep the widget prefix"
880            );
881            assert!(
882                !name.contains(char::is_whitespace),
883                "{name:?} must be a single CSS class token"
884            );
885            assert!(name.is_ascii(), "{name:?} must stay ASCII");
886            // stable across calls, and equal for equal kinds
887            assert_eq!(name, kind.class_name());
888        }
889    }
890
891    #[test]
892    fn class_name_is_unique_per_kind() {
893        let mut names: Vec<&str> = ALL_KINDS.iter().map(|k| k.class_name()).collect();
894        names.sort_unstable();
895        names.dedup();
896        assert_eq!(names.len(), 4, "every kind needs its own class name");
897    }
898
899    #[test]
900    fn class_name_never_collides_with_the_structural_classes() {
901        // the kind classes live in a different namespace than the three
902        // `__azul-native-toast*` structural classes emitted by `dom()`
903        let structural = ["__azul-native-toast", "__azul-native-toast-message",
904                          "__azul-native-toast-close"];
905        for kind in ALL_KINDS {
906            for s in structural {
907                assert_ne!(kind.class_name(), s, "{kind:?} must not shadow {s:?}");
908            }
909        }
910    }
911
912    #[test]
913    fn class_name_is_const_evaluable() {
914        const DANGER: &str = ToastKind::Danger.class_name();
915        assert_eq!(DANGER, "__azul-toast-danger");
916    }
917
918    // ------------------------------------------------------------------
919    // build_toast_style
920    // ------------------------------------------------------------------
921
922    #[test]
923    fn build_toast_style_declares_the_same_properties_for_every_kind() {
924        let info = property_types(&build_toast_style(ToastKind::Info));
925        assert_eq!(
926            info.len(),
927            32,
928            "the container style declares 32 properties (pin: adding/removing one is a \
929             deliberate change)"
930        );
931
932        for kind in ALL_KINDS {
933            let style = build_toast_style(kind);
934            assert_eq!(
935                property_types(&style),
936                info,
937                "{kind:?} must declare the same properties, in the same order, as Info"
938            );
939            // the style is unconditional: nothing is gated behind :hover/@media/...
940            for p in style.as_ref() {
941                assert!(
942                    p.apply_if.as_ref().is_empty(),
943                    "{kind:?}: {:?} must be unconditional",
944                    p.property
945                );
946            }
947        }
948    }
949
950    #[test]
951    fn build_toast_style_declares_no_property_twice() {
952        // a duplicated property would silently shadow the earlier declaration
953        for kind in ALL_KINDS {
954            let types = property_types(&build_toast_style(kind));
955            for (i, a) in types.iter().enumerate() {
956                for b in &types[i + 1..] {
957                    assert_ne!(
958                        a, b,
959                        "{kind:?}: the container style declares the same property twice"
960                    );
961                }
962            }
963        }
964    }
965
966    #[test]
967    fn build_toast_style_colors_track_the_kind_palette() {
968        for kind in ALL_KINDS {
969            let style = build_toast_style(kind);
970            let (bg, border, text) = kind.colors();
971
972            assert_eq!(background_color(&style), Some(bg), "{kind:?}: background");
973            assert_eq!(text_color(&style), Some(text), "{kind:?}: text colour");
974
975            let borders = border_colors(&style);
976            assert_eq!(borders.len(), 4, "{kind:?}: all four edges must be coloured");
977            assert!(
978                borders.iter().all(|c| *c == border),
979                "{kind:?}: every edge must use the kind's border colour, got {borders:?}"
980            );
981        }
982    }
983
984    #[test]
985    fn build_toast_style_pins_the_card_to_the_bottom_right_corner() {
986        // This is what makes a toast a toast (rather than an inline alert):
987        // position:absolute + bottom/right insets + a width cap.
988        for kind in ALL_KINDS {
989            let style = build_toast_style(kind);
990
991            assert_eq!(
992                position_of(&style),
993                Some(LayoutPosition::Absolute),
994                "{kind:?}: a toast must float out of flow"
995            );
996            assert_eq!(
997                bottom_px(&style),
998                Some(LayoutInsetBottom::const_px(TOAST_INSET).inner),
999                "{kind:?}: bottom inset"
1000            );
1001            assert_eq!(
1002                right_px(&style),
1003                Some(LayoutRight::const_px(TOAST_INSET).inner),
1004                "{kind:?}: right inset"
1005            );
1006            assert_eq!(
1007                max_width_px(&style),
1008                Some(LayoutMaxWidth::const_px(TOAST_MAX_WIDTH).inner),
1009                "{kind:?}: width cap"
1010            );
1011            // an absolutely-positioned card must not also try to grow in a flex row
1012            assert!(
1013                style.as_ref().contains(&CssPropertyWithConditions::simple(
1014                    CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
1015                )),
1016                "{kind:?}: the container must not flex-grow"
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn toast_inset_and_max_width_survive_the_fixed_point_encoding_exactly() {
1023        // The `isize`-backed `FloatValue` encoding must reproduce the constants
1024        // bit-exactly — a drifting inset silently mis-places every toast.
1025        assert_eq!(TOAST_INSET, 24);
1026        assert_eq!(TOAST_MAX_WIDTH, 360);
1027        assert!(
1028            TOAST_INSET > 0 && TOAST_MAX_WIDTH > TOAST_INSET,
1029            "an inset must push the card inward, and the cap must exceed the inset"
1030        );
1031
1032        let style = build_toast_style(ToastKind::Info);
1033        for (name, got, want) in [
1034            ("bottom", bottom_px(&style), TOAST_INSET),
1035            ("right", right_px(&style), TOAST_INSET),
1036            ("max-width", max_width_px(&style), TOAST_MAX_WIDTH),
1037        ] {
1038            let pv = got.unwrap_or_else(|| panic!("{name} must be declared"));
1039            assert_eq!(
1040                pv.metric,
1041                SizeMetric::Px,
1042                "{name} must be an absolute px length, not a %/em"
1043            );
1044            assert!(
1045                (pv.number.get() - want as f32).abs() < f32::EPSILON,
1046                "{name}: {} px decoded back as {}",
1047                want,
1048                pv.number.get()
1049            );
1050            assert!(
1051                pv.number.get().is_finite(),
1052                "{name} must never decode to NaN/inf"
1053            );
1054        }
1055    }
1056
1057    #[test]
1058    fn build_toast_style_geometry_is_kind_independent() {
1059        // Everything that is *not* a colour must be identical for all kinds.
1060        let expected = [
1061            CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
1062            CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
1063                LayoutFlexDirection::Row,
1064            )),
1065            CssPropertyWithConditions::simple(CssProperty::const_align_items(
1066                LayoutAlignItems::Start,
1067            )),
1068            CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
1069            CssPropertyWithConditions::simple(CssProperty::const_padding_top(
1070                LayoutPaddingTop::const_px(12),
1071            )),
1072            CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
1073                LayoutBorderTopWidth::const_px(1),
1074            )),
1075            CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
1076                StyleBorderTopLeftRadius::const_px(6),
1077            )),
1078            CssPropertyWithConditions::simple(CssProperty::const_font_size(
1079                StyleFontSize::const_px(14),
1080            )),
1081        ];
1082
1083        for kind in ALL_KINDS {
1084            let style = build_toast_style(kind);
1085            for want in &expected {
1086                assert!(
1087                    style.as_ref().contains(want),
1088                    "{kind:?}: missing {:?}",
1089                    want.property
1090                );
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn build_toast_style_differs_only_in_the_colours() {
1097        let info = build_toast_style(ToastKind::Info);
1098        for kind in [ToastKind::Success, ToastKind::Warning, ToastKind::Danger] {
1099            let other = build_toast_style(kind);
1100            let differing: Vec<_> = info
1101                .as_ref()
1102                .iter()
1103                .zip(other.as_ref().iter())
1104                .filter(|(a, b)| a != b)
1105                .map(|(a, _)| core::mem::discriminant(&a.property))
1106                .collect();
1107
1108            // background + 4 border colours + text colour = 6 kind-dependent props
1109            assert_eq!(
1110                differing.len(),
1111                6,
1112                "{kind:?}: only bg + 4 border colours + text colour may depend on the kind"
1113            );
1114        }
1115    }
1116
1117    #[test]
1118    fn build_toast_style_is_pure_and_repeatable() {
1119        for kind in ALL_KINDS {
1120            assert_eq!(
1121                build_toast_style(kind),
1122                build_toast_style(kind),
1123                "{kind:?}: the builder must be deterministic"
1124            );
1125        }
1126    }
1127
1128    // ------------------------------------------------------------------
1129    // Toast::create / with_kind / Default
1130    // ------------------------------------------------------------------
1131
1132    #[test]
1133    fn create_is_an_info_toast_that_is_dismissible_by_default() {
1134        let toast = Toast::create(AzString::from("hello"));
1135
1136        assert_eq!(toast.message.as_str(), "hello");
1137        assert_eq!(toast.kind, ToastKind::Info);
1138        assert!(
1139            toast.dismissible,
1140            "unlike Alert, a fresh Toast ships the close button (the only way to dismiss it)"
1141        );
1142        assert!(toast.toast_state.inner.visible, "a fresh toast is visible");
1143        assert!(toast.toast_state.on_dismiss.is_none());
1144        assert_eq!(toast.container_style, build_toast_style(ToastKind::Info));
1145    }
1146
1147    #[test]
1148    fn toast_state_defaults_to_visible_not_to_the_bool_default() {
1149        // `bool::default()` is false — `ToastState` must *override* that, else
1150        // every default-constructed toast would start out already dismissed.
1151        assert!(ToastState::default().visible);
1152        assert!(ToastStateWrapper::default().inner.visible);
1153        assert!(ToastStateWrapper::default().on_dismiss.is_none());
1154        assert!(Toast::default().toast_state.inner.visible);
1155    }
1156
1157    #[test]
1158    fn create_with_empty_message_equals_default_and_is_value_comparable() {
1159        assert_eq!(Toast::create(AzString::from("")), Toast::default());
1160        // equality is structural, not pointer-based
1161        assert_eq!(
1162            Toast::create(AzString::from("a")),
1163            Toast::create(AzString::from("a"))
1164        );
1165        assert_ne!(
1166            Toast::create(AzString::from("a")),
1167            Toast::create(AzString::from("b"))
1168        );
1169        assert_ne!(
1170            Toast::create(AzString::from("a")),
1171            Toast::with_kind(AzString::from("a"), ToastKind::Danger)
1172        );
1173    }
1174
1175    #[test]
1176    fn create_survives_extreme_messages_and_round_trips_them_into_the_dom() {
1177        let long = "ab".repeat(50_000);
1178        let cases: Vec<AzString> = alloc::vec![
1179            AzString::from(""),
1180            AzString::from(" "),
1181            AzString::from("a\0b"),                                  // interior NUL
1182            AzString::from("line\nbreak\ttab"),                      // control chars
1183            AzString::from("👨‍👩‍👧‍👦 e\u{0301}\u{0327} مرحبا שלום 🇩🇪"), // ZWJ + combining + RTL
1184            AzString::from("\u{feff}\u{202e}rtl-override"),          // BOM + bidi override
1185            AzString::from("×"),                                     // same glyph as the close button
1186            AzString::from("\u{00D7}\u{00D7}\u{00D7}"),              // three close glyphs
1187            AzString::from(long.as_str()),                           // 100k chars
1188        ];
1189
1190        for message in cases {
1191            let toast = Toast::create(message.clone());
1192            assert_eq!(toast.message.as_str(), message.as_str());
1193
1194            // the message must survive the trip through the DOM byte-for-byte
1195            let dom = toast.dom();
1196            let children = dom.children.as_ref();
1197            assert_eq!(
1198                children.len(),
1199                2,
1200                "message content must never change the child count"
1201            );
1202            assert_eq!(text_of(&children[0]), Some(message.as_str()));
1203            // a "×" in the *message* must not be mistaken for the close button
1204            assert!(
1205                children[0].root.has_class("__azul-native-toast-message"),
1206                "the first child is always the message"
1207            );
1208            assert!(
1209                children[1].root.has_class("__azul-native-toast-close"),
1210                "the close button is always last"
1211            );
1212        }
1213    }
1214
1215    #[test]
1216    fn with_kind_stores_both_args_for_every_kind() {
1217        for kind in ALL_KINDS {
1218            let toast = Toast::with_kind(AzString::from("m"), kind);
1219
1220            assert_eq!(toast.kind, kind);
1221            assert_eq!(toast.message.as_str(), "m");
1222            assert!(toast.dismissible);
1223            assert!(toast.toast_state.on_dismiss.is_none());
1224            assert!(toast.toast_state.inner.visible);
1225            assert_eq!(
1226                toast.container_style,
1227                build_toast_style(kind),
1228                "{kind:?}: the container style must match the kind it was built with"
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn create_is_with_kind_info() {
1235        assert_eq!(
1236            Toast::create(AzString::from("m")),
1237            Toast::with_kind(AzString::from("m"), ToastKind::Info)
1238        );
1239        assert_eq!(
1240            Toast::create(AzString::from("m")),
1241            Toast::with_kind(AzString::from("m"), ToastKind::default())
1242        );
1243    }
1244
1245    // ------------------------------------------------------------------
1246    // set_kind / with_toast_kind
1247    // ------------------------------------------------------------------
1248
1249    #[test]
1250    fn set_kind_recomputes_the_style_and_is_idempotent() {
1251        let mut toast = Toast::create(AzString::from("m"));
1252
1253        for kind in ALL_KINDS {
1254            toast.set_kind(kind);
1255            assert_eq!(toast.kind, kind);
1256            assert_eq!(toast.container_style, build_toast_style(kind));
1257
1258            // applying the same kind twice must not append/duplicate anything
1259            let before = toast.container_style.clone();
1260            toast.set_kind(kind);
1261            assert_eq!(
1262                toast.container_style, before,
1263                "{kind:?}: set_kind must be idempotent"
1264            );
1265            assert_eq!(
1266                toast.container_style.len(),
1267                32,
1268                "{kind:?}: restyling must not grow the property vec"
1269            );
1270        }
1271
1272        // a full cycle back to the original kind restores the original toast
1273        let original = Toast::create(AzString::from("m"));
1274        let mut cycled = original.clone();
1275        for kind in ALL_KINDS {
1276            cycled.set_kind(kind);
1277        }
1278        cycled.set_kind(ToastKind::Info);
1279        assert_eq!(cycled, original, "kind cycling must not accumulate state");
1280    }
1281
1282    #[test]
1283    fn set_kind_leaves_message_dismissible_and_callback_alone() {
1284        let log = RefAny::new(DismissLog { calls: Vec::new() });
1285        let mut toast = Toast::create(AzString::from("keep me"));
1286        toast.set_on_dismiss(log, dismiss_cb(dismiss_do_nothing));
1287        toast.toast_state.inner.visible = false;
1288
1289        toast.set_kind(ToastKind::Warning);
1290
1291        assert_eq!(toast.message.as_str(), "keep me");
1292        assert!(toast.dismissible, "set_kind must not clear the close button");
1293        assert!(
1294            toast.toast_state.on_dismiss.is_some(),
1295            "set_kind must not drop the callback"
1296        );
1297        assert!(
1298            !toast.toast_state.inner.visible,
1299            "set_kind must not resurrect a dismissed toast"
1300        );
1301    }
1302
1303    #[test]
1304    fn with_toast_kind_matches_set_kind_and_last_write_wins() {
1305        for kind in ALL_KINDS {
1306            let built = Toast::create(AzString::from("m")).with_toast_kind(kind);
1307            let mut mutated = Toast::create(AzString::from("m"));
1308            mutated.set_kind(kind);
1309            assert_eq!(built, mutated, "{kind:?}: builder and setter must agree");
1310        }
1311
1312        let toast = Toast::create(AzString::from("m"))
1313            .with_toast_kind(ToastKind::Danger)
1314            .with_toast_kind(ToastKind::Success);
1315        assert_eq!(toast.kind, ToastKind::Success);
1316        assert_eq!(toast.container_style, build_toast_style(ToastKind::Success));
1317    }
1318
1319    // ------------------------------------------------------------------
1320    // set_dismissible / with_dismissible
1321    // ------------------------------------------------------------------
1322
1323    #[test]
1324    fn set_dismissible_last_write_wins_and_touches_nothing_else() {
1325        let mut toast = Toast::with_kind(AzString::from("m"), ToastKind::Warning);
1326        let style_before = toast.container_style.clone();
1327
1328        for flag in [true, true, false, true, false, false] {
1329            toast.set_dismissible(flag);
1330            assert_eq!(toast.dismissible, flag);
1331        }
1332
1333        assert_eq!(toast.kind, ToastKind::Warning);
1334        assert_eq!(toast.message.as_str(), "m");
1335        assert_eq!(
1336            toast.container_style, style_before,
1337            "toggling must not restyle"
1338        );
1339        assert!(
1340            toast.toast_state.on_dismiss.is_none(),
1341            "toggling must not invent a callback"
1342        );
1343        assert!(
1344            toast.toast_state.inner.visible,
1345            "toggling the close button must not hide the toast"
1346        );
1347    }
1348
1349    #[test]
1350    fn with_dismissible_toggle_sequence_ends_on_the_last_value() {
1351        assert!(Toast::default().with_dismissible(true).dismissible);
1352        assert!(!Toast::default().with_dismissible(false).dismissible);
1353        assert!(
1354            !Toast::default()
1355                .with_dismissible(true)
1356                .with_dismissible(false)
1357                .dismissible
1358        );
1359        assert!(
1360            Toast::default()
1361                .with_dismissible(false)
1362                .with_dismissible(true)
1363                .dismissible
1364        );
1365        // builder == setter
1366        let mut mutated = Toast::default();
1367        mutated.set_dismissible(false);
1368        assert_eq!(Toast::default().with_dismissible(false), mutated);
1369        // and re-enabling restores the exact default value
1370        assert_eq!(
1371            Toast::default()
1372                .with_dismissible(false)
1373                .with_dismissible(true),
1374            Toast::default()
1375        );
1376    }
1377
1378    // ------------------------------------------------------------------
1379    // set_on_dismiss / with_on_dismiss
1380    // ------------------------------------------------------------------
1381
1382    #[test]
1383    fn set_on_dismiss_forces_dismissible_back_on() {
1384        let mut toast = Toast::create(AzString::from("m")).with_dismissible(false);
1385        assert!(!toast.dismissible);
1386
1387        toast.set_on_dismiss(RefAny::new(1u8), dismiss_cb(dismiss_do_nothing));
1388
1389        assert!(
1390            toast.dismissible,
1391            "a dismiss callback must re-render the close button"
1392        );
1393        assert!(toast.toast_state.on_dismiss.is_some());
1394        assert!(
1395            toast.toast_state.inner.visible,
1396            "wiring a callback must not hide the toast"
1397        );
1398    }
1399
1400    #[test]
1401    fn set_on_dismiss_replaces_rather_than_appends() {
1402        let mut toast = Toast::create(AzString::from("m"));
1403
1404        toast.set_on_dismiss(RefAny::new(1u8), dismiss_cb(dismiss_do_nothing));
1405        let first = toast
1406            .toast_state
1407            .on_dismiss
1408            .as_ref()
1409            .expect("first callback")
1410            .refany
1411            .get_type_id();
1412        assert_eq!(first, RefAny::new(1u8).get_type_id());
1413
1414        // a second call must *replace* the payload + function, not stack another one
1415        toast.set_on_dismiss(RefAny::new(9i64), dismiss_cb(record_dismiss));
1416        let second = toast.toast_state.on_dismiss.as_ref().expect("second callback");
1417        assert_eq!(second.refany.get_type_id(), RefAny::new(9i64).get_type_id());
1418        assert_eq!(second.callback, dismiss_cb(record_dismiss));
1419        assert_ne!(second.callback, dismiss_cb(dismiss_do_nothing));
1420    }
1421
1422    #[test]
1423    fn with_on_dismiss_keeps_message_and_kind() {
1424        let toast = Toast::with_kind(AzString::from("boom"), ToastKind::Danger)
1425            .with_on_dismiss(RefAny::new(0u8), dismiss_cb(dismiss_do_nothing));
1426
1427        assert_eq!(toast.message.as_str(), "boom");
1428        assert_eq!(toast.kind, ToastKind::Danger);
1429        assert_eq!(toast.container_style, build_toast_style(ToastKind::Danger));
1430        assert!(toast.dismissible);
1431        assert!(toast.toast_state.on_dismiss.is_some());
1432    }
1433
1434    #[test]
1435    fn set_dismissible_false_after_set_on_dismiss_silently_drops_the_close_button() {
1436        // Footgun, pinned as the *current* behaviour: `set_on_dismiss` forces
1437        // `dismissible = true`, but a later `set_dismissible(false)` wins and the
1438        // wired-up callback becomes unreachable. For a toast this is worse than
1439        // for an alert: the "x" is the *only* dismissal path (no auto-timeout),
1440        // so the toast can never be dismissed at all.
1441        let mut toast = Toast::create(AzString::from("m"));
1442        toast.set_on_dismiss(RefAny::new(0u8), dismiss_cb(record_dismiss));
1443        toast.set_dismissible(false);
1444
1445        assert!(
1446            toast.toast_state.on_dismiss.is_some(),
1447            "the callback is still stored"
1448        );
1449        let dom = toast.dom();
1450        assert_eq!(
1451            dom.children.as_ref().len(),
1452            1,
1453            "no close button is rendered, so the toast is undismissable"
1454        );
1455        assert!(
1456            dom.children.as_ref()[0]
1457                .root
1458                .get_callbacks()
1459                .as_ref()
1460                .is_empty(),
1461            "and no handler is attached anywhere else either"
1462        );
1463    }
1464
1465    // ------------------------------------------------------------------
1466    // swap_with_default
1467    // ------------------------------------------------------------------
1468
1469    #[test]
1470    fn swap_with_default_returns_the_original_and_resets_self() {
1471        let mut toast =
1472            Toast::with_kind(AzString::from("payload"), ToastKind::Danger).with_dismissible(false);
1473        let snapshot = toast.clone();
1474
1475        let returned = toast.swap_with_default();
1476
1477        assert_eq!(returned, snapshot, "the original must come back untouched");
1478        assert_eq!(toast, Toast::default(), "self must be reset to a default toast");
1479        assert_eq!(toast.message.as_str(), "");
1480        assert_eq!(toast.kind, ToastKind::Info);
1481        assert!(
1482            toast.dismissible,
1483            "the reset toast is a *default* toast, so it is dismissible again"
1484        );
1485        assert!(toast.toast_state.on_dismiss.is_none());
1486        assert!(toast.toast_state.inner.visible);
1487    }
1488
1489    #[test]
1490    fn swap_with_default_is_stable_when_repeated() {
1491        let mut toast = Toast::default();
1492        for _ in 0..3 {
1493            let returned = toast.swap_with_default();
1494            assert_eq!(returned, Toast::default());
1495            assert_eq!(toast, Toast::default());
1496        }
1497    }
1498
1499    #[test]
1500    fn swap_with_default_moves_the_callback_out_of_self() {
1501        let mut toast = Toast::create(AzString::from("m"))
1502            .with_on_dismiss(RefAny::new(7u32), dismiss_cb(record_dismiss));
1503
1504        let returned = toast.swap_with_default();
1505
1506        assert!(
1507            returned.toast_state.on_dismiss.is_some(),
1508            "the callback moves out"
1509        );
1510        assert!(
1511            toast.toast_state.on_dismiss.is_none(),
1512            "the reset toast must not keep a reference to the old callback"
1513        );
1514    }
1515
1516    #[test]
1517    fn swap_with_default_round_trips_a_dismissed_toast() {
1518        // a toast that was already dismissed must hand its `visible == false`
1519        // state to the caller, not silently reset it in the returned value
1520        let mut toast = Toast::create(AzString::from("m"));
1521        toast.toast_state.inner.visible = false;
1522
1523        let returned = toast.swap_with_default();
1524
1525        assert!(!returned.toast_state.inner.visible);
1526        assert!(toast.toast_state.inner.visible, "the fresh toast is visible");
1527    }
1528
1529    // ------------------------------------------------------------------
1530    // Toast::dom
1531    // ------------------------------------------------------------------
1532
1533    #[test]
1534    fn dom_of_a_default_toast_is_a_container_with_message_and_close() {
1535        let toast = Toast::create(AzString::from("hi"));
1536        let style = toast.container_style.clone();
1537        let dom = toast.dom();
1538
1539        assert!(dom.root.has_class("__azul-native-toast"));
1540        assert!(
1541            dom.root.get_callbacks().as_ref().is_empty(),
1542            "the container itself must carry no live callback"
1543        );
1544        assert_eq!(
1545            dom.root.style.iter_inline_properties().count(),
1546            style.len(),
1547            "every container property must reach the node's inline style"
1548        );
1549
1550        let children = dom.children.as_ref();
1551        assert_eq!(children.len(), 2, "[message, close]");
1552        assert!(children[0].root.has_class("__azul-native-toast-message"));
1553        assert_eq!(text_of(&children[0]), Some("hi"));
1554        assert!(children[0].root.get_callbacks().as_ref().is_empty());
1555        assert!(children[0].root.get_tab_index().is_none());
1556    }
1557
1558    #[test]
1559    fn dom_children_carry_exactly_the_static_child_styles() {
1560        let dom = Toast::create(AzString::from("hi")).dom();
1561        let children = dom.children.as_ref();
1562
1563        let want_message: Vec<CssProperty> = TOAST_MESSAGE_STYLE
1564            .iter()
1565            .map(|p| p.property.clone())
1566            .collect();
1567        let want_close: Vec<CssProperty> = TOAST_CLOSE_STYLE
1568            .iter()
1569            .map(|p| p.property.clone())
1570            .collect();
1571
1572        assert_eq!(inline_props(&children[0]), want_message);
1573        assert_eq!(inline_props(&children[1]), want_close);
1574
1575        // the message takes the free space, the close button never does
1576        assert!(want_message.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))));
1577        assert!(want_close.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
1578        // ... and the "x" must not be text-selectable / must show a pointer
1579        assert!(want_close.contains(&CssProperty::const_cursor(StyleCursor::Pointer)));
1580        assert!(want_close.contains(&CssProperty::user_select(StyleUserSelect::None)));
1581    }
1582
1583    #[test]
1584    fn dom_close_button_is_focusable_and_wired_to_the_dismiss_handler() {
1585        let dom = Toast::create(AzString::from("hi")).dom();
1586
1587        let children = dom.children.as_ref();
1588        assert_eq!(children.len(), 2);
1589
1590        let close = &children[1];
1591        assert!(close.root.has_class("__azul-native-toast-close"));
1592        assert_eq!(
1593            text_of(close),
1594            Some("\u{00D7}"),
1595            "the close glyph is U+00D7 MULTIPLICATION SIGN"
1596        );
1597        assert!(
1598            matches!(close.root.get_tab_index(), Some(TabIndex::Auto)),
1599            "the close button must be keyboard-reachable"
1600        );
1601
1602        let callbacks = close.root.get_callbacks();
1603        assert_eq!(callbacks.as_ref().len(), 1, "exactly one dismiss handler");
1604        let cb = &callbacks.as_ref()[0];
1605        assert!(matches!(
1606            &cb.event,
1607            EventFilter::Hover(HoverEventFilter::MouseUp)
1608        ));
1609        assert_eq!(cb.callback.cb, default_on_toast_dismiss as usize);
1610        assert!(matches!(&cb.callback.ctx, OptionRefAny::None));
1611    }
1612
1613    #[test]
1614    fn dom_hands_the_toast_state_to_the_close_button() {
1615        let toast = Toast::create(AzString::from("hi"))
1616            .with_on_dismiss(RefAny::new(0u8), dismiss_cb(record_dismiss));
1617        let dom = toast.dom();
1618
1619        let close = &dom.children.as_ref()[1];
1620        let mut payload = close.root.get_callbacks().as_ref()[0].refany.clone();
1621
1622        assert!(
1623            wrapper_visible(&mut payload),
1624            "the close button must receive a live, visible ToastStateWrapper"
1625        );
1626        assert!(
1627            payload
1628                .downcast_ref::<ToastStateWrapper>()
1629                .expect("ToastStateWrapper")
1630                .on_dismiss
1631                .is_some(),
1632            "the user callback must travel with the state"
1633        );
1634    }
1635
1636    #[test]
1637    fn dom_of_a_non_dismissible_toast_has_no_callbacks_at_all() {
1638        let dom = Toast::create(AzString::from("m")).with_dismissible(false).dom();
1639
1640        assert!(dom.root.has_class("__azul-native-toast"));
1641        let children = dom.children.as_ref();
1642        assert_eq!(children.len(), 1, "no close button without `dismissible`");
1643        assert!(children[0].root.has_class("__azul-native-toast-message"));
1644        assert!(children[0].root.get_callbacks().as_ref().is_empty());
1645        assert!(dom.root.get_callbacks().as_ref().is_empty());
1646    }
1647
1648    #[test]
1649    fn dom_renders_even_when_the_state_says_hidden() {
1650        // Pinned current behaviour: `visible` is *only* consulted by the dismiss
1651        // handler (which restyles the live node); `dom()` ignores it, so a
1652        // pre-dismissed toast is still emitted at full size.  A host that
1653        // rebuilds its DOM must filter dismissed toasts out itself.
1654        let mut toast = Toast::create(AzString::from("gone"));
1655        toast.toast_state.inner.visible = false;
1656
1657        let dom = toast.dom();
1658        assert_eq!(dom.children.as_ref().len(), 2);
1659        assert_eq!(text_of(&dom.children.as_ref()[0]), Some("gone"));
1660
1661        let close = &dom.children.as_ref()[1];
1662        let mut payload = close.root.get_callbacks().as_ref()[0].refany.clone();
1663        assert!(
1664            !wrapper_visible(&mut payload),
1665            "the hidden state travels into the DOM verbatim"
1666        );
1667    }
1668
1669    #[test]
1670    fn dom_is_stable_across_kinds_and_the_kind_class_is_not_emitted() {
1671        for kind in ALL_KINDS {
1672            let dom = Toast::with_kind(AzString::from("m"), kind).dom();
1673            assert!(dom.root.has_class("__azul-native-toast"));
1674            assert_eq!(dom.children.as_ref().len(), 2);
1675
1676            // NOTE: `ToastKind::class_name()` is *not* applied to the DOM - the
1677            // container only ever carries the generic container class.
1678            assert!(
1679                !dom.root.has_class(kind.class_name()),
1680                "current behaviour: the kind class is not emitted"
1681            );
1682        }
1683    }
1684
1685    #[test]
1686    fn from_toast_for_dom_is_exactly_dom() {
1687        // non-dismissible, so no RefAny identity is involved in the comparison
1688        let toast = Toast::with_kind(AzString::from("m"), ToastKind::Success)
1689            .with_dismissible(false);
1690        let via_from = Dom::from(toast.clone());
1691        let via_method = toast.dom();
1692        assert!(
1693            via_from == via_method,
1694            "`impl From<Toast> for Dom` must delegate to `Toast::dom`"
1695        );
1696    }
1697
1698    // ------------------------------------------------------------------
1699    // default_on_toast_dismiss
1700    // ------------------------------------------------------------------
1701
1702    #[test]
1703    fn dismiss_hides_the_container_and_flips_visible() {
1704        let mut data = RefAny::new(ToastStateWrapper::default());
1705
1706        // node 2 == the close button, its parent (node 0) is the container
1707        let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 2, data.clone());
1708
1709        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
1710        assert_eq!(
1711            display_writes(&changes),
1712            alloc::vec![(0usize, LayoutDisplay::None)],
1713            "the *container* (not the close button) must be hidden"
1714        );
1715        assert!(!wrapper_visible(&mut data), "state must flip to hidden");
1716    }
1717
1718    #[test]
1719    fn dismiss_invokes_the_user_callback_with_the_already_flipped_state() {
1720        let mut log = RefAny::new(DismissLog { calls: Vec::new() });
1721        let mut data = RefAny::new(ToastStateWrapper {
1722            inner: ToastState { visible: true },
1723            on_dismiss: Some(ToastOnDismiss {
1724                callback: dismiss_cb(record_dismiss),
1725                refany: log.clone(),
1726            })
1727            .into(),
1728        });
1729
1730        let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 2, data.clone());
1731
1732        assert_eq!(
1733            update,
1734            Update::RefreshDom,
1735            "the user callback's Update is returned"
1736        );
1737        assert_eq!(
1738            log_calls(&mut log),
1739            alloc::vec![false],
1740            "the callback must see `visible == false` (already dismissed)"
1741        );
1742        assert!(!wrapper_visible(&mut data));
1743        assert_eq!(
1744            display_writes(&changes),
1745            alloc::vec![(0usize, LayoutDisplay::None)],
1746            "the container is hidden even after a user callback ran"
1747        );
1748    }
1749
1750    #[test]
1751    fn dismiss_twice_is_idempotent() {
1752        let mut log = RefAny::new(DismissLog { calls: Vec::new() });
1753        let mut data = RefAny::new(ToastStateWrapper {
1754            inner: ToastState { visible: true },
1755            on_dismiss: Some(ToastOnDismiss {
1756                callback: dismiss_cb(record_dismiss),
1757                refany: log.clone(),
1758            })
1759            .into(),
1760        });
1761
1762        for _ in 0..2 {
1763            let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 2, data.clone());
1764            assert_eq!(update, Update::RefreshDom);
1765            assert_eq!(
1766                display_writes(&changes),
1767                alloc::vec![(0usize, LayoutDisplay::None)]
1768            );
1769        }
1770
1771        assert!(!wrapper_visible(&mut data), "a second dismiss must not un-hide");
1772        assert_eq!(
1773            log_calls(&mut log),
1774            alloc::vec![false, false],
1775            "each click fires the callback exactly once, always with visible == false"
1776        );
1777    }
1778
1779    #[test]
1780    fn dismiss_from_the_message_node_also_hides_the_container() {
1781        // Pinned: the handler hides `parent(hit)`, whatever the hit node is.
1782        // For the 3-node toast the message's parent is the container too, so a
1783        // mis-wired handler would still "work" - which is why the close button
1784        // must stay the only node carrying it (see the wiring test above).
1785        let mut data = RefAny::new(ToastStateWrapper::default());
1786
1787        let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 1, data.clone());
1788
1789        assert_eq!(update, Update::DoNothing);
1790        assert_eq!(
1791            display_writes(&changes),
1792            alloc::vec![(0usize, LayoutDisplay::None)]
1793        );
1794        assert!(!wrapper_visible(&mut data));
1795    }
1796
1797    #[test]
1798    fn dismiss_on_a_root_hit_node_is_a_noop() {
1799        // node 0 has no parent -> there is no container to hide
1800        let mut data = RefAny::new(ToastStateWrapper::default());
1801
1802        let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 0, data.clone());
1803
1804        assert_eq!(update, Update::DoNothing);
1805        assert!(changes.is_empty(), "nothing may be restyled without a parent");
1806        assert!(wrapper_visible(&mut data), "state must not flip");
1807    }
1808
1809    #[test]
1810    fn dismiss_with_a_stale_hit_node_is_a_noop() {
1811        // node 999 does not exist in the 3-node fixture
1812        let mut data = RefAny::new(ToastStateWrapper::default());
1813
1814        let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 999, data.clone());
1815
1816        assert_eq!(update, Update::DoNothing);
1817        assert!(changes.is_empty());
1818        assert!(wrapper_visible(&mut data));
1819    }
1820
1821    #[test]
1822    fn dismiss_with_an_absurd_hit_node_index_does_not_panic() {
1823        // usize::MAX / 2 is far past any allocated NodeId
1824        let mut data = RefAny::new(ToastStateWrapper::default());
1825
1826        let (update, changes) =
1827            run_dismiss(Some(dismissible_styled_dom()), usize::MAX / 2, data.clone());
1828
1829        assert_eq!(update, Update::DoNothing);
1830        assert!(changes.is_empty());
1831        assert!(wrapper_visible(&mut data));
1832    }
1833
1834    #[test]
1835    fn dismiss_without_any_layout_result_is_a_noop() {
1836        let mut data = RefAny::new(ToastStateWrapper::default());
1837
1838        let (update, changes) = run_dismiss(None, 2, data.clone());
1839
1840        assert_eq!(update, Update::DoNothing);
1841        assert!(changes.is_empty());
1842        assert!(wrapper_visible(&mut data), "state must not flip");
1843    }
1844
1845    #[test]
1846    fn dismiss_with_a_foreign_payload_is_a_noop() {
1847        // the callback-bearing node carries a RefAny of the *wrong* type
1848        let data = RefAny::new(0xdead_beef_u64);
1849
1850        let (update, changes) = run_dismiss(Some(dismissible_styled_dom()), 2, data.clone());
1851
1852        assert_eq!(update, Update::DoNothing);
1853        assert!(
1854            changes.is_empty(),
1855            "a foreign payload must not hide the container"
1856        );
1857    }
1858
1859    #[test]
1860    fn dismiss_end_to_end_through_the_real_dom_payload() {
1861        // Take the *actual* RefAny the widget wired into its close button and
1862        // drive the *actual* handler the widget registered against it.
1863        let toast = Toast::create(AzString::from("bye"));
1864        let dom = toast.dom();
1865        let close = &dom.children.as_ref()[1];
1866        let entry = &close.root.get_callbacks().as_ref()[0];
1867        assert_eq!(entry.callback.cb, default_on_toast_dismiss as usize);
1868        let mut payload = entry.refany.clone();
1869
1870        let styled = StyledDom::create_from_dom(dom);
1871        let (update, changes) = run_dismiss(Some(styled), 2, payload.clone());
1872
1873        assert_eq!(update, Update::DoNothing);
1874        assert_eq!(
1875            display_writes(&changes),
1876            alloc::vec![(0usize, LayoutDisplay::None)]
1877        );
1878        assert!(
1879            !wrapper_visible(&mut payload),
1880            "the state living in the DOM must be flipped to hidden"
1881        );
1882    }
1883
1884    #[test]
1885    fn dismiss_end_to_end_reaches_a_user_callback_wired_through_the_builder() {
1886        let mut log = RefAny::new(DismissLog { calls: Vec::new() });
1887        let dom = Toast::with_kind(AzString::from("bye"), ToastKind::Danger)
1888            .with_on_dismiss(log.clone(), dismiss_cb(record_dismiss))
1889            .dom();
1890        let payload = dom.children.as_ref()[1]
1891            .root
1892            .get_callbacks()
1893            .as_ref()[0]
1894            .refany
1895            .clone();
1896
1897        let styled = StyledDom::create_from_dom(dom);
1898        let (update, changes) = run_dismiss(Some(styled), 2, payload);
1899
1900        assert_eq!(update, Update::RefreshDom);
1901        assert_eq!(
1902            display_writes(&changes),
1903            alloc::vec![(0usize, LayoutDisplay::None)]
1904        );
1905        assert_eq!(
1906            log_calls(&mut log),
1907            alloc::vec![false],
1908            "the builder-wired callback must fire exactly once, with visible == false"
1909        );
1910    }
1911}