Skip to main content

azul_layout/widgets/
modal.rs

1//! Modal / dialog widget — an in-app overlay dialog (NOT the native OS file/
2//! message dialogs, which live in the `dialog` module; this is the custom in-app
3//! variant). A blend of [`crate::widgets::frame::Frame`] (the bordered, elevated
4//! content panel) and [`crate::widgets::popover::Popover`] (overlay show/hide via
5//! `set_css_property(display)` driven by a toggled state).
6//!
7//! Structure: a full-area *backdrop* (`position: absolute`, covering its parent,
8//! semi-transparent black) that centres a *panel* holding an optional title, an
9//! optional "x" close button (absolutely positioned in the panel's top-right
10//! corner), and the arbitrary `content: Dom`. The whole thing is hidden by
11//! default (`display: none`) and shown by building it with `with_open(true)` (or
12//! by the host flipping it). Clicking the close button flips `open` to `false`,
13//! invokes the optional user `on_close(state)`, and hides the backdrop via
14//! `set_css_property(display: none)` (mirroring popover's live restyle).
15//!
16//! TODO2 — several "real modal" behaviours are NOT reachable from a widget
17//! handler and are deliberately omitted (be honest rather than fake them):
18//!   * **Focus-trap** (confining keyboard focus to the dialog while open) depends
19//!     on the focus model and is not controllable from a widget handler.
20//!   * **Escape-to-close** depends on a global key handler the widget does not own
21//!     (the panel/backdrop are not keyboard-focused), so it is not wired.
22//!   * **Backdrop-click-to-close** is NOT wired: with `currentTarget` hit
23//!     semantics (see `popover`), a click handler on the backdrop reports the
24//!     backdrop as the hit node even when the *panel* (a descendant) was clicked,
25//!     so it cannot distinguish an outside click from an inside click — wiring it
26//!     would close the dialog when clicking its own content. Only the explicit "x"
27//!     closes it.
28//!   * **Covering sibling widgets**: the backdrop is `position: absolute` and
29//!     relies on paint order (being a later sibling) to overlay other content;
30//!     there is no real stacking-context / z-index. Place the modal as the LAST
31//!     child of a positioned, full-size container for a correct overlay.
32//!   * The `display:none/flex` relayout itself is not GUI-verified in this build.
33//!
34//! Key types: [`Modal`], [`ModalState`], [`ModalOnClose`].
35
36use azul_core::{
37    callbacks::{CoreCallback, CoreCallbackData, Update},
38    dom::{Dom, DomVec, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
39    refany::RefAny,
40};
41use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
42use azul_css::{
43    props::{
44        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, PixelValue, StyleFontSize},
45        layout::{LayoutDisplay, LayoutPosition, LayoutTop, LayoutLeft, LayoutWidth, LayoutHeight, LayoutFlexDirection, LayoutJustifyContent, LayoutAlignItems, LayoutFlexGrow, LayoutMinWidth, LayoutMaxWidth, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutRight},
46        property::{CssProperty, *},
47        style::{StyleBackgroundContentVec, StyleBackgroundContent, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextColor, StyleTextAlign, StyleUserSelect, StyleCursor},
48    },
49    impl_option_inner, AzString,
50};
51
52use crate::callbacks::{Callback, CallbackInfo};
53
54static MODAL_BACKDROP_CLASS: &[IdOrClass] =
55    &[Class(AzString::from_const_str("__azul-native-modal"))];
56static MODAL_PANEL_CLASS: &[IdOrClass] =
57    &[Class(AzString::from_const_str("__azul-native-modal-panel"))];
58static MODAL_TITLE_CLASS: &[IdOrClass] =
59    &[Class(AzString::from_const_str("__azul-native-modal-title"))];
60static MODAL_CLOSE_CLASS: &[IdOrClass] =
61    &[Class(AzString::from_const_str("__azul-native-modal-close"))];
62static MODAL_CONTENT_CLASS: &[IdOrClass] =
63    &[Class(AzString::from_const_str("__azul-native-modal-content"))];
64
65const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
66const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
67const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
68    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
69
70// ---- layout (logical px) ----
71const PANEL_MIN_WIDTH: isize = 280;
72const PANEL_MAX_WIDTH: isize = 520;
73const PANEL_RADIUS: isize = 8;
74
75// ---- colours ----
76/// Semi-transparent black backdrop (rgba(0,0,0,0.5)).
77const BACKDROP_COLOR: ColorU = ColorU { r: 0, g: 0, b: 0, a: 128 };
78const PANEL_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
79const PANEL_BORDER_COLOR: ColorU = ColorU { r: 204, g: 204, b: 204, a: 255 }; // #cccccc
80const TITLE_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 }; // #212529
81const CLOSE_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 }; // #6c757d
82
83/// Callback invoked when the modal's "x" close button is clicked. The
84/// [`ModalState`] carries the *new* (`false`) open value.
85pub type ModalOnCloseCallbackType = extern "C" fn(RefAny, CallbackInfo, ModalState) -> Update;
86impl_widget_callback!(
87    ModalOnClose,
88    OptionModalOnClose,
89    ModalOnCloseCallback,
90    ModalOnCloseCallbackType
91);
92
93azul_core::impl_managed_callback! {
94    wrapper:        ModalOnCloseCallback,
95    info_ty:        CallbackInfo,
96    return_ty:      Update,
97    default_ret:    Update::DoNothing,
98    invoker_static: MODAL_ON_CLOSE_INVOKER,
99    invoker_ty:     AzModalOnCloseCallbackInvoker,
100    thunk_fn:       az_modal_on_close_callback_thunk,
101    setter_fn:      AzApp_setModalOnCloseCallbackInvoker,
102    from_handle_fn: AzModalOnCloseCallback_createFromHostHandle,
103    extra_args:     [ state: ModalState ],
104}
105
106/// An in-app overlay dialog holding arbitrary content, with an optional title and
107/// close button.
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[repr(C)]
110pub struct Modal {
111    /// Runtime state (`open`) plus the optional close callback.
112    pub modal_state: ModalStateWrapper,
113    /// The dialog title (empty = no title bar).
114    pub title: AzString,
115    /// The arbitrary content shown inside the panel.
116    pub content: Dom,
117    /// Whether to render the "x" close button.
118    pub show_close_button: bool,
119    /// Style of the full-area backdrop (includes its current `display`).
120    pub backdrop_style: CssPropertyWithConditionsVec,
121}
122
123#[derive(Debug, Default, Clone, PartialEq, Eq)]
124#[repr(C)]
125pub struct ModalStateWrapper {
126    /// Whether the dialog is currently open (shown).
127    pub inner: ModalState,
128    /// Optional: function to call when the dialog is closed.
129    pub on_close: OptionModalOnClose,
130}
131
132/// The open/closed state of a [`Modal`].
133#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
134#[repr(C)]
135pub struct ModalState {
136    /// `true` = dialog shown, `false` (default) = dialog hidden.
137    pub open: bool,
138}
139
140/// Builds the backdrop style. Only the `display` (open vs closed) differs; all
141/// other props are present in both so the runtime `set_css_property(display)`
142/// toggle has everything it needs (mirroring popover/accordion).
143fn build_backdrop_style(open: bool) -> CssPropertyWithConditionsVec {
144    let display = if open {
145        LayoutDisplay::Flex
146    } else {
147        LayoutDisplay::None
148    };
149    let bg_vec = StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(
150        BACKDROP_COLOR
151    )]);
152    CssPropertyWithConditionsVec::from_vec(alloc::vec![
153        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
154        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
155        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(0))),
156        CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
157        // Cover the full parent (see the z-order TODO2 — depends on a full-size,
158        // positioned parent).
159        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::Px(
160            PixelValue::const_percent(100),
161        ))),
162        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::Px(
163            PixelValue::const_percent(100),
164        ))),
165        // Centre the panel.
166        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
167            LayoutFlexDirection::Row,
168        )),
169        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
170            LayoutJustifyContent::Center,
171        )),
172        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
173        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
174        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
175    ])
176}
177
178/// The centred dialog panel: a bordered, rounded white box (frame-like). Elevation
179/// is conveyed by the dimmed backdrop behind it + the border/radius; a drop
180/// `box-shadow` is intentionally omitted (it requires a runtime-heap shadow value
181/// — see `progressbar.rs` — and is not needed for a clear modal read).
182static MODAL_PANEL_STYLE: &[CssPropertyWithConditions] = &[
183    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
184    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
185    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
186    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
187    CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
188        PANEL_MIN_WIDTH,
189    ))),
190    CssPropertyWithConditions::simple(CssProperty::const_max_width(LayoutMaxWidth::const_px(
191        PANEL_MAX_WIDTH,
192    ))),
193    // padding: 20px
194    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(20))),
195    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
196        LayoutPaddingBottom::const_px(20),
197    )),
198    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
199        20,
200    ))),
201    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
202        LayoutPaddingRight::const_px(20),
203    )),
204    // border: 1px solid #cccccc
205    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
206        LayoutBorderTopWidth::const_px(1),
207    )),
208    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
209        LayoutBorderBottomWidth::const_px(1),
210    )),
211    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
212        LayoutBorderLeftWidth::const_px(1),
213    )),
214    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
215        LayoutBorderRightWidth::const_px(1),
216    )),
217    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
218        inner: BorderStyle::Solid,
219    })),
220    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
221        StyleBorderBottomStyle {
222            inner: BorderStyle::Solid,
223        },
224    )),
225    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
226        inner: BorderStyle::Solid,
227    })),
228    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
229        StyleBorderRightStyle {
230            inner: BorderStyle::Solid,
231        },
232    )),
233    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
234        inner: PANEL_BORDER_COLOR,
235    })),
236    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
237        StyleBorderBottomColor {
238            inner: PANEL_BORDER_COLOR,
239        },
240    )),
241    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
242        inner: PANEL_BORDER_COLOR,
243    })),
244    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
245        StyleBorderRightColor {
246            inner: PANEL_BORDER_COLOR,
247        },
248    )),
249    // border-radius: 8px
250    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
251        StyleBorderTopLeftRadius::const_px(PANEL_RADIUS),
252    )),
253    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
254        StyleBorderTopRightRadius::const_px(PANEL_RADIUS),
255    )),
256    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
257        StyleBorderBottomLeftRadius::const_px(PANEL_RADIUS),
258    )),
259    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
260        StyleBorderBottomRightRadius::const_px(PANEL_RADIUS),
261    )),
262    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
263    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
264    CssPropertyWithConditions::simple(CssProperty::const_background_content(
265        StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(
266            PANEL_BG_COLOR,
267        )]),
268    )),
269];
270
271/// Title style: larger, bold-ish dark text with a bottom gap; right padding keeps
272/// it clear of the absolutely-positioned "x".
273static MODAL_TITLE_STYLE: &[CssPropertyWithConditions] = &[
274    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
275    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
276    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
277        inner: TITLE_COLOR,
278    })),
279    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
280    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
281        LayoutPaddingRight::const_px(24),
282    )),
283    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
284        LayoutPaddingBottom::const_px(12),
285    )),
286    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
287];
288
289/// "x" close-button style: an absolutely-positioned pointer-cursor glyph in the
290/// panel's top-right corner.
291static MODAL_CLOSE_STYLE: &[CssPropertyWithConditions] = &[
292    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
293    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(8))),
294    CssPropertyWithConditions::simple(CssProperty::const_right(LayoutRight::const_px(12))),
295    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(22))),
296    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
297        inner: CLOSE_COLOR,
298    })),
299    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
300    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
301];
302
303/// Content-wrapper style: takes the remaining vertical space.
304static MODAL_CONTENT_STYLE: &[CssPropertyWithConditions] = &[
305    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
306    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
307];
308
309impl Modal {
310    /// Creates a new (closed) modal holding `content`, with a "x" close button and
311    /// no title.
312    #[must_use] pub fn create(content: Dom) -> Self {
313        Self {
314            modal_state: ModalStateWrapper::default(),
315            title: AzString::from_const_str(""),
316            content,
317            show_close_button: true,
318            backdrop_style: build_backdrop_style(false),
319        }
320    }
321
322    /// Sets the dialog title (empty = no title).
323    #[inline]
324    pub fn set_title(&mut self, title: AzString) {
325        self.title = title;
326    }
327
328    /// Builder-style setter for the title.
329    #[inline]
330    #[must_use] pub fn with_title(mut self, title: AzString) -> Self {
331        self.set_title(title);
332        self
333    }
334
335    /// Replaces the content shown inside the panel.
336    #[inline]
337    pub fn set_content(&mut self, content: Dom) {
338        self.content = content;
339    }
340
341    /// Builder-style setter for the content.
342    #[inline]
343    #[must_use] pub fn with_content(mut self, content: Dom) -> Self {
344        self.set_content(content);
345        self
346    }
347
348    /// Sets whether the dialog is currently open, recomputing the backdrop style.
349    #[inline]
350    pub fn set_open(&mut self, open: bool) {
351        self.modal_state.inner.open = open;
352        self.backdrop_style = build_backdrop_style(open);
353    }
354
355    /// Builder-style setter for the initial open state.
356    #[inline]
357    #[must_use] pub fn with_open(mut self, open: bool) -> Self {
358        self.set_open(open);
359        self
360    }
361
362    /// Sets whether the "x" close button is shown.
363    #[inline]
364    pub const fn set_close_button(&mut self, show: bool) {
365        self.show_close_button = show;
366    }
367
368    /// Builder-style setter for the close-button flag.
369    #[inline]
370    #[must_use] pub const fn with_close_button(mut self, show: bool) -> Self {
371        self.set_close_button(show);
372        self
373    }
374
375    /// Sets the close callback (invoked with the new state when "x" is clicked).
376    #[inline]
377    pub fn set_on_close<C: Into<ModalOnCloseCallback>>(&mut self, data: RefAny, on_close: C) {
378        self.modal_state.on_close = Some(ModalOnClose {
379            callback: on_close.into(),
380            refany: data,
381        })
382        .into();
383    }
384
385    /// Builder-style setter for the close callback.
386    #[inline]
387    #[must_use] pub fn with_on_close<C: Into<ModalOnCloseCallback>>(
388        mut self,
389        data: RefAny,
390        on_close: C,
391    ) -> Self {
392        self.set_on_close(data, on_close);
393        self
394    }
395
396    /// Replaces `self` with a default (empty, closed) modal and returns the original.
397    #[inline]
398    #[must_use] pub fn swap_with_default(&mut self) -> Self {
399        let mut s = Self::create(Dom::default());
400        core::mem::swap(&mut s, self);
401        s
402    }
403
404    /// Renders the modal into a [`Dom`] subtree with the `__azul-native-modal`
405    /// class (the backdrop).
406    #[must_use] pub fn dom(self) -> Dom {
407        // Panel children: [close?, title?, content]. The close button is
408        // absolutely positioned (top-right), so its document order does not affect
409        // the title/content stacking.
410        let mut panel_children = Vec::new();
411
412        if self.show_close_button {
413            let close = Dom::create_text(AzString::from_const_str("\u{00D7}"))
414                .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_CLOSE_CLASS))
415                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_CLOSE_STYLE))
416                .with_tab_index(TabIndex::Auto)
417                .with_callbacks(
418                    alloc::vec![CoreCallbackData {
419                        event: azul_core::dom::EventFilter::Hover(
420                            azul_core::dom::HoverEventFilter::MouseUp,
421                        ),
422                        callback: CoreCallback {
423                            cb: on_modal_close as usize,
424                            ctx: azul_core::refany::OptionRefAny::None,
425                        },
426                        refany: RefAny::new(self.modal_state),
427                    }]
428                    .into(),
429                );
430            panel_children.push(close);
431        }
432
433        if !self.title.as_str().is_empty() {
434            let title = Dom::create_text(self.title)
435                .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_TITLE_CLASS))
436                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_TITLE_STYLE));
437            panel_children.push(title);
438        }
439
440        let content = Dom::create_div()
441            .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_CONTENT_CLASS))
442            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_CONTENT_STYLE))
443            .with_children(DomVec::from_vec(alloc::vec![self.content]));
444        panel_children.push(content);
445
446        let panel = Dom::create_div()
447            .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_PANEL_CLASS))
448            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_PANEL_STYLE))
449            .with_children(DomVec::from_vec(panel_children));
450
451        Dom::create_div()
452            .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_BACKDROP_CLASS))
453            .with_css_props(self.backdrop_style)
454            .with_children(DomVec::from_vec(alloc::vec![panel]))
455    }
456}
457
458impl Default for Modal {
459    fn default() -> Self {
460        Self::create(Dom::default())
461    }
462}
463
464/// "x" close-button click handler. The hit node is the close button (the
465/// callback-bearing node, per `currentTarget` semantics — see `popover`); its
466/// parent is the panel and the panel's parent is the backdrop. Flips `open` to
467/// `false`, invokes the optional user callback, then hides the backdrop via
468/// `display: none`.
469extern "C" fn on_modal_close(mut data: RefAny, mut info: CallbackInfo) -> Update {
470    let close_node = info.get_hit_node();
471    let Some(panel) = info.get_parent(close_node) else {
472        return Update::DoNothing;
473    };
474    let Some(backdrop) = info.get_parent(panel) else {
475        return Update::DoNothing;
476    };
477
478    let result = {
479        let Some(mut modal) = data.downcast_mut::<ModalStateWrapper>() else {
480            return Update::DoNothing;
481        };
482        modal.inner.open = false;
483        let inner = modal.inner;
484        let modal = &mut *modal;
485        match modal.on_close.as_mut() {
486            Some(ModalOnClose { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
487            None => Update::DoNothing,
488        }
489    };
490
491    // TODO2: hides the whole dialog by toggling `display: none` via
492    // set_css_property (the proven live-restyle pattern of popover/alert); the
493    // relayout itself is not GUI-verified in this build.
494    info.set_css_property(backdrop, CssProperty::const_display(LayoutDisplay::None));
495
496    result
497}
498
499impl From<Modal> for Dom {
500    fn from(m: Modal) -> Self {
501        m.dom()
502    }
503}
504
505#[cfg(test)]
506mod autotest_generated {
507    use std::{
508        collections::{BTreeMap, HashMap},
509        sync::{Arc, Mutex},
510    };
511
512    use azul_core::{
513        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
514        geom::{LogicalRect, OptionLogicalPosition},
515        gl::OptionGlContextPtr,
516        hit_test::ScrollPosition,
517        refany::OptionRefAny,
518        resources::RendererResources,
519        styled_dom::{NodeHierarchyItemId, StyledDom},
520        window::{MonitorVec, RawWindowHandle},
521    };
522    use azul_css::system::SystemStyle;
523    use rust_fontconfig::FcFontCache;
524
525    use super::*;
526    #[cfg(feature = "icu")]
527    use crate::icu::IcuLocalizerHandle;
528    use crate::{
529        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
530        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
531        window::{DomLayoutResult, LayoutWindow},
532        window_state::FullWindowState,
533    };
534
535    // ------------------------------------------------------------------
536    // Helpers
537    // ------------------------------------------------------------------
538
539    /// Titles a caller can realistically hand to a modal. The widget never parses
540    /// or normalises its title — every one of these has to reach the DOM
541    /// byte-for-byte, and every *non-empty* one has to produce a title node (the
542    /// `is_empty()` gate is byte-length based, so a zero-width space counts).
543    const ADVERSARIAL_TEXT: [&str; 8] = [
544        "",
545        " ",
546        "a\0b",
547        "e\u{0301}\u{0301}\u{0301}",
548        "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
549        "\u{202E}gnirts desrever\u{202C}",
550        "\u{FFFD}\u{FEFF}\t\n",
551        "\u{200B}",
552    ];
553
554    /// True if `node` carries the CSS class `name`.
555    fn has_class(node: &Dom, name: &str) -> bool {
556        node.root
557            .get_ids_and_classes()
558            .as_ref()
559            .iter()
560            .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
561    }
562
563    /// The text of a `NodeType::Text` node (`None` for any other node type).
564    fn text_of(node: &Dom) -> Option<&str> {
565        match node.root.get_node_type() {
566            NodeType::Text(s) => Some(s.as_ref().as_str()),
567            _ => None,
568        }
569    }
570
571    /// The properties of a style vec, in declaration order.
572    fn style_props(style: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
573        style.as_ref().iter().map(|p| p.property.clone()).collect()
574    }
575
576    /// The *kind* of every declared property, in order (ignores the values).
577    fn property_types(
578        style: &CssPropertyWithConditionsVec,
579    ) -> Vec<core::mem::Discriminant<CssProperty>> {
580        style
581            .as_ref()
582            .iter()
583            .map(|p| core::mem::discriminant(&p.property))
584            .collect()
585    }
586
587    /// A node's *inline* style properties, in declaration order.
588    fn inline_props(node: &Dom) -> Vec<CssProperty> {
589        node.root
590            .style
591            .iter_inline_properties()
592            .map(|(p, _)| p.clone())
593            .collect()
594    }
595
596    /// The `display` value declared in a style vec.
597    fn display_of(style: &CssPropertyWithConditionsVec) -> Option<LayoutDisplay> {
598        style.as_ref().iter().find_map(|p| match &p.property {
599            CssProperty::Display(v) => v.get_property().copied(),
600            _ => None,
601        })
602    }
603
604    /// The `display` value in a node's *inline* style.
605    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
606        node.root
607            .style
608            .iter_inline_properties()
609            .find_map(|(p, _)| match p {
610                CssProperty::Display(v) => v.get_property().copied(),
611                _ => None,
612            })
613    }
614
615    /// The `background-color` of a style vec (first background layer only).
616    fn background_color(style: &CssPropertyWithConditionsVec) -> Option<ColorU> {
617        style.as_ref().iter().find_map(|p| match &p.property {
618            CssProperty::BackgroundContent(v) => match v.get_property()?.as_ref().first()? {
619                StyleBackgroundContent::Color(c) => Some(*c),
620                _ => None,
621            },
622            _ => None,
623        })
624    }
625
626    /// The one and only child of the backdrop: the panel.
627    fn panel(dom: &Dom) -> &Dom {
628        let kids = dom.children.as_ref();
629        assert_eq!(kids.len(), 1, "the backdrop must have exactly one child");
630        assert!(
631            has_class(&kids[0], "__azul-native-modal-panel"),
632            "the backdrop's only child must be the panel"
633        );
634        &kids[0]
635    }
636
637    fn panel_kids(dom: &Dom) -> &[Dom] {
638        panel(dom).children.as_ref()
639    }
640
641    /// The first node in the tree carrying `class` (pre-order).
642    fn find_class<'a>(dom: &'a Dom, class: &str) -> Option<&'a Dom> {
643        if has_class(dom, class) {
644            return Some(dom);
645        }
646        dom.children
647            .as_ref()
648            .iter()
649            .find_map(|c| find_class(c, class))
650    }
651
652    /// Total number of nodes in a `Dom` tree.
653    fn node_count(dom: &Dom) -> usize {
654        1 + dom
655            .children
656            .as_ref()
657            .iter()
658            .map(node_count)
659            .sum::<usize>()
660    }
661
662    /// Total number of callbacks registered anywhere in a `Dom` tree.
663    fn count_callbacks(dom: &Dom) -> usize {
664        dom.root.get_callbacks().as_ref().len()
665            + dom
666                .children
667                .as_ref()
668                .iter()
669                .map(count_callbacks)
670                .sum::<usize>()
671    }
672
673    /// A `depth`-deep chain of nested divs (adversarial content).
674    fn nested_content(depth: usize) -> Dom {
675        let mut d = Dom::create_div();
676        for _ in 0..depth {
677            d = Dom::create_div().with_child(d);
678        }
679        d
680    }
681
682    /// The exact backdrop style the widget documents, for a given `display`.
683    fn expected_backdrop(display: LayoutDisplay) -> Vec<CssProperty> {
684        alloc::vec![
685            CssProperty::const_display(display),
686            CssProperty::const_position(LayoutPosition::Absolute),
687            CssProperty::const_top(LayoutTop::const_px(0)),
688            CssProperty::const_left(LayoutLeft::const_px(0)),
689            CssProperty::const_width(LayoutWidth::Px(PixelValue::const_percent(100))),
690            CssProperty::const_height(LayoutHeight::Px(PixelValue::const_percent(100))),
691            CssProperty::const_flex_direction(LayoutFlexDirection::Row),
692            CssProperty::const_justify_content(LayoutJustifyContent::Center),
693            CssProperty::const_align_items(LayoutAlignItems::Center),
694            CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0)),
695            CssProperty::const_background_content(StyleBackgroundContentVec::from_vec(
696                alloc::vec![StyleBackgroundContent::Color(BACKDROP_COLOR)]
697            )),
698        ]
699    }
700
701    /// A `RefAny` payload recording every `ModalState` a user `on_close` sees.
702    struct CloseLog {
703        calls: Vec<bool>,
704    }
705
706    extern "C" fn record_close(mut data: RefAny, _: CallbackInfo, state: ModalState) -> Update {
707        if let Some(mut log) = data.downcast_mut::<CloseLog>() {
708            log.calls.push(state.open);
709        }
710        Update::RefreshDom
711    }
712
713    extern "C" fn close_do_nothing(_: RefAny, _: CallbackInfo, _: ModalState) -> Update {
714        Update::DoNothing
715    }
716
717    fn close_cb(f: ModalOnCloseCallbackType) -> ModalOnCloseCallback {
718        f.into()
719    }
720
721    /// `open` of a `ModalStateWrapper` payload.
722    fn wrapper_open(data: &mut RefAny) -> bool {
723        data.downcast_ref::<ModalStateWrapper>()
724            .expect("payload must still be a ModalStateWrapper")
725            .inner
726            .open
727    }
728
729    /// The `open` flags recorded by a `CloseLog` payload.
730    fn log_calls(data: &mut RefAny) -> Vec<bool> {
731        data.downcast_ref::<CloseLog>()
732            .expect("payload must still be a CloseLog")
733            .calls
734            .clone()
735    }
736
737    /// A `DomLayoutResult` with an *empty* layout tree: the close handler only
738    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
739    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
740        DomLayoutResult {
741            styled_dom,
742            layout_tree: LayoutTree {
743                nodes: Vec::new(),
744                warm: Vec::new(),
745                cold: Vec::new(),
746                root: 0,
747                dom_to_layout: BTreeMap::new(),
748                children_arena: Vec::new(),
749                children_offsets: Vec::new(),
750                subtree_needs_intrinsic: Vec::new(),
751            },
752            calculated_positions: Vec::new(),
753            viewport: LogicalRect::zero(),
754            display_list: DisplayList::default(),
755            scroll_ids: HashMap::new(),
756            scroll_id_to_node_id: HashMap::new(),
757        }
758    }
759
760    /// The flattened DOM of a default modal: `backdrop(0)`, `panel(1)`,
761    /// `close(2)`, `content-wrapper(3)`, `content(4)` — i.e. exactly the
762    /// hierarchy `on_modal_close` walks (hit node -> parent -> parent).
763    fn modal_styled_dom() -> StyledDom {
764        let styled = StyledDom::create_from_dom(Modal::create(Dom::create_div()).dom());
765        assert_eq!(
766            styled.node_hierarchy.as_ref().len(),
767            5,
768            "fixture must flatten to backdrop/panel/close/wrapper/content"
769        );
770        styled
771    }
772
773    /// Invokes `on_modal_close` against a `LayoutWindow` holding `styled` (or
774    /// nothing at all, when `styled` is `None`), with `hit` as the hit node.
775    /// Returns the `Update` plus every recorded `CallbackChange`.
776    fn run_close(
777        styled: Option<StyledDom>,
778        hit: usize,
779        data: RefAny,
780    ) -> (Update, Vec<CallbackChange>) {
781        let mut layout_window =
782            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
783        if let Some(sd) = styled {
784            layout_window
785                .layout_results
786                .insert(DomId::ROOT_ID, layout_result(sd));
787        }
788
789        let renderer_resources = RendererResources::default();
790        let previous_window_state: Option<FullWindowState> = None;
791        let current_window_state = FullWindowState::default();
792        let gl_context = OptionGlContextPtr::None;
793        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
794            BTreeMap::new();
795        let window_handle = RawWindowHandle::Unsupported;
796        let system_callbacks = ExternalSystemCallbacks::rust_internal();
797
798        let ref_data = CallbackInfoRefData {
799            layout_window: &layout_window,
800            renderer_resources: &renderer_resources,
801            previous_window_state: &previous_window_state,
802            current_window_state: &current_window_state,
803            gl_context: &gl_context,
804            current_scroll_manager: &scroll_states,
805            current_window_handle: &window_handle,
806            system_callbacks: &system_callbacks,
807            system_style: Arc::new(SystemStyle::default()),
808            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
809            #[cfg(feature = "icu")]
810            icu_localizer: IcuLocalizerHandle::default(),
811            ctx: OptionRefAny::None,
812        };
813
814        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
815
816        let info = CallbackInfo::new(
817            &ref_data,
818            &changes,
819            DomNodeId {
820                dom: DomId::ROOT_ID,
821                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
822            },
823            OptionLogicalPosition::None,
824            OptionLogicalPosition::None,
825        );
826
827        let update = on_modal_close(data, info);
828        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
829        (update, recorded)
830    }
831
832    /// Every `display` write recorded in the change log, as `(node index, display)`.
833    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
834        let mut out = Vec::new();
835        for change in changes {
836            if let CallbackChange::ChangeNodeCssProperties {
837                node_id, properties, ..
838            } = change
839            {
840                for p in properties.as_ref() {
841                    if let CssProperty::Display(v) = p {
842                        if let Some(d) = v.get_property() {
843                            out.push((node_id.index(), *d));
844                        }
845                    }
846                }
847            }
848        }
849        out
850    }
851
852    // ------------------------------------------------------------------
853    // build_backdrop_style
854    // ------------------------------------------------------------------
855
856    #[test]
857    fn build_backdrop_style_emits_the_documented_property_list() {
858        assert_eq!(
859            style_props(&build_backdrop_style(true)),
860            expected_backdrop(LayoutDisplay::Flex)
861        );
862        assert_eq!(
863            style_props(&build_backdrop_style(false)),
864            expected_backdrop(LayoutDisplay::None)
865        );
866    }
867
868    #[test]
869    fn build_backdrop_style_differs_only_in_the_display() {
870        // The doc comment promises the toggle has *everything* it needs: both
871        // variants must declare the same properties, in the same order, with the
872        // same values — except `display`.
873        let open = style_props(&build_backdrop_style(true));
874        let closed = style_props(&build_backdrop_style(false));
875
876        assert_eq!(open.len(), closed.len());
877        let diffs: Vec<usize> = (0..open.len()).filter(|i| open[*i] != closed[*i]).collect();
878        assert_eq!(diffs, alloc::vec![0usize], "only entry 0 (display) may differ");
879
880        assert_eq!(display_of(&build_backdrop_style(true)), Some(LayoutDisplay::Flex));
881        assert_eq!(display_of(&build_backdrop_style(false)), Some(LayoutDisplay::None));
882    }
883
884    #[test]
885    fn build_backdrop_style_declares_no_property_twice() {
886        // a duplicated property would silently shadow the earlier declaration —
887        // and would make the runtime `display` toggle ambiguous
888        for open in [true, false] {
889            let types = property_types(&build_backdrop_style(open));
890            for (i, a) in types.iter().enumerate() {
891                for b in &types[i + 1..] {
892                    assert_ne!(a, b, "open={open}: the backdrop declares the same property twice");
893                }
894            }
895        }
896    }
897
898    #[test]
899    fn build_backdrop_style_is_unconditional() {
900        // nothing may be gated behind :hover/@media/... or the closed modal could
901        // become visible under the wrong conditions
902        for open in [true, false] {
903            for p in build_backdrop_style(open).as_ref() {
904                assert!(
905                    p.apply_if.as_ref().is_empty(),
906                    "open={open}: {:?} must be unconditional",
907                    p.property
908                );
909            }
910        }
911    }
912
913    #[test]
914    fn build_backdrop_style_is_pure_and_repeatable() {
915        for open in [true, false] {
916            assert_eq!(build_backdrop_style(open), build_backdrop_style(open));
917        }
918        assert_ne!(build_backdrop_style(true), build_backdrop_style(false));
919    }
920
921    #[test]
922    fn build_backdrop_style_dims_with_half_transparent_black() {
923        for open in [true, false] {
924            let style = build_backdrop_style(open);
925            assert_eq!(
926                background_color(&style),
927                Some(ColorU { r: 0, g: 0, b: 0, a: 128 }),
928                "the backdrop must stay rgba(0,0,0,0.5) in both states"
929            );
930        }
931        // a fully opaque or fully transparent backdrop would be a regression
932        let alpha = BACKDROP_COLOR.a;
933        assert!(alpha > 0 && alpha < 255, "backdrop alpha {alpha} must dim, not erase");
934    }
935
936    // ------------------------------------------------------------------
937    // Modal::create / Default
938    // ------------------------------------------------------------------
939
940    #[test]
941    fn create_is_a_closed_modal_with_a_close_button_and_no_title() {
942        let content = Dom::create_div().with_child(Dom::create_text("hi"));
943        let m = Modal::create(content.clone());
944
945        assert!(!m.modal_state.inner.open, "a fresh modal must start closed");
946        assert!(m.modal_state.on_close.is_none());
947        assert_eq!(m.title.as_str(), "");
948        assert_eq!(m.content, content);
949        assert!(m.show_close_button, "the 'x' is on by default");
950        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::None));
951    }
952
953    #[test]
954    fn default_equals_create_with_a_default_dom() {
955        assert_eq!(Modal::default(), Modal::create(Dom::default()));
956        assert_eq!(Modal::default(), Modal::default());
957        assert!(!Modal::default().modal_state.inner.open);
958    }
959
960    #[test]
961    fn create_survives_extreme_content() {
962        // deeply nested, very wide, and empty content must all be stored verbatim
963        assert_eq!(node_count(&Modal::create(nested_content(200)).content), 201);
964
965        let wide = Dom::create_div().with_children(DomVec::from_vec(
966            (0..5_000).map(|_| Dom::create_div()).collect::<Vec<_>>(),
967        ));
968        assert_eq!(node_count(&Modal::create(wide).content), 5_001);
969
970        // a modal whose content is *another* modal's DOM
971        let inner = Modal::create(Dom::create_div()).with_title(AzString::from("inner"));
972        let nested = Modal::create(inner.dom());
973        assert_eq!(nested.content.children.as_ref().len(), 1);
974    }
975
976    #[test]
977    fn create_is_clone_and_value_comparable() {
978        let m = Modal::create(Dom::create_div()).with_title(AzString::from("t"));
979        assert_eq!(m.clone(), m);
980        assert_ne!(m, Modal::create(Dom::create_div()));
981    }
982
983    // ------------------------------------------------------------------
984    // Modal::set_title / with_title
985    // ------------------------------------------------------------------
986
987    #[test]
988    fn set_title_stores_every_adversarial_string_byte_for_byte() {
989        for s in ADVERSARIAL_TEXT {
990            let mut m = Modal::create(Dom::create_div());
991            m.set_title(AzString::from(s));
992            assert_eq!(m.title.as_str(), s, "title {s:?} must round-trip unchanged");
993            assert_eq!(m.title.as_str().len(), s.len(), "no normalisation may happen");
994        }
995    }
996
997    #[test]
998    fn set_title_survives_a_100k_char_title() {
999        let long = "ä\u{0301}".repeat(50_000);
1000        let mut m = Modal::create(Dom::create_div());
1001        m.set_title(AzString::from(long.as_str()));
1002        assert_eq!(m.title.as_str(), long);
1003
1004        let dom = m.dom();
1005        assert_eq!(
1006            text_of(&panel_kids(&dom)[1]),
1007            Some(long.as_str()),
1008            "the huge title must reach the DOM intact"
1009        );
1010    }
1011
1012    #[test]
1013    fn with_title_matches_set_title_and_last_write_wins() {
1014        for s in ADVERSARIAL_TEXT {
1015            let built = Modal::create(Dom::create_div()).with_title(AzString::from(s));
1016            let mut mutated = Modal::create(Dom::create_div());
1017            mutated.set_title(AzString::from(s));
1018            assert_eq!(built, mutated);
1019        }
1020
1021        let m = Modal::create(Dom::create_div())
1022            .with_title(AzString::from("first"))
1023            .with_title(AzString::from("second"))
1024            .with_title(AzString::from(""));
1025        assert_eq!(m.title.as_str(), "", "the last write must win, even an empty one");
1026    }
1027
1028    #[test]
1029    fn set_title_touches_nothing_else() {
1030        let content = Dom::create_div().with_child(Dom::create_text("body"));
1031        let mut m = Modal::create(content.clone()).with_open(true);
1032        let before = m.backdrop_style.clone();
1033
1034        m.set_title(AzString::from("Title"));
1035
1036        assert!(m.modal_state.inner.open, "the title must not close the dialog");
1037        assert_eq!(m.backdrop_style, before, "the title must not rebuild the backdrop");
1038        assert_eq!(m.content, content);
1039        assert!(m.show_close_button);
1040    }
1041
1042    #[test]
1043    fn only_a_byte_empty_title_suppresses_the_title_node() {
1044        for s in ADVERSARIAL_TEXT {
1045            let dom = Modal::create(Dom::create_div())
1046                .with_title(AzString::from(s))
1047                .dom();
1048            let title = find_class(&dom, "__azul-native-modal-title");
1049
1050            if s.is_empty() {
1051                assert!(title.is_none(), "an empty title must emit no title node");
1052            } else {
1053                let title = title.expect("a non-empty title must emit a title node");
1054                assert_eq!(
1055                    text_of(title),
1056                    Some(s),
1057                    "the title node must carry the string verbatim"
1058                );
1059            }
1060        }
1061        // documented consequence: a zero-width space is "non-empty", so it emits a
1062        // title node that renders as nothing but still consumes the title slot
1063        let zwsp = Modal::create(Dom::create_div())
1064            .with_title(AzString::from("\u{200B}"))
1065            .dom();
1066        assert!(find_class(&zwsp, "__azul-native-modal-title").is_some());
1067        assert_eq!(panel_kids(&zwsp).len(), 3, "close + (invisible) title + content");
1068    }
1069
1070    // ------------------------------------------------------------------
1071    // Modal::set_content / with_content
1072    // ------------------------------------------------------------------
1073
1074    #[test]
1075    fn set_content_replaces_and_last_write_wins() {
1076        let a = Dom::create_div().with_child(Dom::create_text("a"));
1077        let b = Dom::create_text("b");
1078
1079        let mut m = Modal::create(a.clone());
1080        assert_eq!(m.content, a);
1081        m.set_content(b.clone());
1082        assert_eq!(m.content, b, "content must be replaced, not merged");
1083        assert_eq!(node_count(&m.content), 1);
1084    }
1085
1086    #[test]
1087    fn with_content_matches_set_content_and_keeps_everything_else() {
1088        let content = nested_content(32);
1089
1090        let built = Modal::create(Dom::create_div())
1091            .with_title(AzString::from("t"))
1092            .with_open(true)
1093            .with_close_button(false)
1094            .with_content(content.clone());
1095
1096        let mut mutated = Modal::create(Dom::create_div())
1097            .with_title(AzString::from("t"))
1098            .with_open(true)
1099            .with_close_button(false);
1100        mutated.set_content(content.clone());
1101
1102        assert_eq!(built, mutated);
1103        assert_eq!(built.content, content);
1104        assert_eq!(built.title.as_str(), "t");
1105        assert!(built.modal_state.inner.open);
1106        assert!(!built.show_close_button);
1107    }
1108
1109    #[test]
1110    fn content_reaches_the_dom_under_the_content_wrapper() {
1111        let content = nested_content(64);
1112        let dom = Modal::create(content.clone()).dom();
1113
1114        let wrapper = find_class(&dom, "__azul-native-modal-content")
1115            .expect("the content wrapper must exist");
1116        let kids = wrapper.children.as_ref();
1117        assert_eq!(kids.len(), 1, "the wrapper holds exactly the user content");
1118        assert_eq!(kids[0], content, "the content must be handed through untouched");
1119    }
1120
1121    // ------------------------------------------------------------------
1122    // Modal::set_open / with_open
1123    // ------------------------------------------------------------------
1124
1125    #[test]
1126    fn set_open_flips_the_state_and_the_backdrop_display_together() {
1127        let mut m = Modal::create(Dom::create_div());
1128
1129        m.set_open(true);
1130        assert!(m.modal_state.inner.open);
1131        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::Flex));
1132
1133        m.set_open(false);
1134        assert!(!m.modal_state.inner.open);
1135        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::None));
1136    }
1137
1138    #[test]
1139    fn set_open_rebuilds_rather_than_appends() {
1140        // an append-instead-of-rebuild bug would grow the vec on every call and
1141        // leave two conflicting `display` declarations behind
1142        let mut m = Modal::create(Dom::create_div());
1143        let len = m.backdrop_style.as_ref().len();
1144
1145        for open in [true, true, false, false, true] {
1146            m.set_open(open);
1147            assert_eq!(
1148                m.backdrop_style.as_ref().len(),
1149                len,
1150                "set_open must rebuild the style, not extend it"
1151            );
1152            let displays: Vec<_> = m
1153                .backdrop_style
1154                .as_ref()
1155                .iter()
1156                .filter(|p| matches!(p.property, CssProperty::Display(_)))
1157                .collect();
1158            assert_eq!(displays.len(), 1, "exactly one `display` may be declared");
1159        }
1160        assert!(m.modal_state.inner.open, "the last write must win");
1161    }
1162
1163    #[test]
1164    fn set_open_is_idempotent() {
1165        let mut once = Modal::create(Dom::create_div());
1166        once.set_open(true);
1167        let mut twice = Modal::create(Dom::create_div());
1168        twice.set_open(true);
1169        twice.set_open(true);
1170        assert_eq!(once, twice);
1171    }
1172
1173    #[test]
1174    fn with_open_matches_set_open_and_keeps_the_other_fields() {
1175        for open in [true, false] {
1176            let built = Modal::create(Dom::create_div())
1177                .with_title(AzString::from("t"))
1178                .with_open(open);
1179            let mut mutated = Modal::create(Dom::create_div()).with_title(AzString::from("t"));
1180            mutated.set_open(open);
1181            assert_eq!(built, mutated);
1182            assert_eq!(built.title.as_str(), "t");
1183            assert!(built.show_close_button);
1184        }
1185    }
1186
1187    #[test]
1188    fn open_state_reaches_the_rendered_backdrop() {
1189        for open in [true, false] {
1190            let dom = Modal::create(Dom::create_div()).with_open(open).dom();
1191            assert_eq!(
1192                inline_display(&dom),
1193                Some(if open { LayoutDisplay::Flex } else { LayoutDisplay::None }),
1194                "open={open}: the backdrop's inline display must match"
1195            );
1196            assert!(has_class(&dom, "__azul-native-modal"));
1197        }
1198    }
1199
1200    // ------------------------------------------------------------------
1201    // Modal::set_close_button / with_close_button
1202    // ------------------------------------------------------------------
1203
1204    #[test]
1205    fn set_close_button_last_write_wins_and_touches_nothing_else() {
1206        let mut m = Modal::create(Dom::create_div())
1207            .with_title(AzString::from("t"))
1208            .with_open(true);
1209        let before = m.backdrop_style.clone();
1210
1211        for show in [false, true, false] {
1212            m.set_close_button(show);
1213            assert_eq!(m.show_close_button, show);
1214        }
1215        assert!(!m.show_close_button);
1216        assert_eq!(m.title.as_str(), "t");
1217        assert!(m.modal_state.inner.open);
1218        assert_eq!(m.backdrop_style, before);
1219    }
1220
1221    #[test]
1222    fn with_close_button_matches_set_close_button() {
1223        for show in [true, false] {
1224            let built = Modal::create(Dom::create_div()).with_close_button(show);
1225            let mut mutated = Modal::create(Dom::create_div());
1226            mutated.set_close_button(show);
1227            assert_eq!(built, mutated);
1228        }
1229    }
1230
1231    #[test]
1232    fn hiding_the_close_button_leaves_the_modal_with_no_way_to_close_itself() {
1233        // TODO2 in the module docs: only the explicit "x" closes the dialog. With
1234        // the button off there is no callback in the whole tree at all.
1235        let dom = Modal::create(Dom::create_div())
1236            .with_close_button(false)
1237            .with_title(AzString::from("stuck"))
1238            .dom();
1239
1240        assert!(find_class(&dom, "__azul-native-modal-close").is_none());
1241        assert_eq!(count_callbacks(&dom), 0, "no handler is wired anywhere");
1242        assert_eq!(panel_kids(&dom).len(), 2, "panel is [title, content] only");
1243    }
1244
1245    // ------------------------------------------------------------------
1246    // Modal::set_on_close / with_on_close
1247    // ------------------------------------------------------------------
1248
1249    #[test]
1250    fn set_on_close_stores_the_callback_and_replaces_rather_than_appends() {
1251        let mut m = Modal::create(Dom::create_div());
1252        assert!(m.modal_state.on_close.is_none());
1253
1254        let first = RefAny::new(1u32);
1255        m.set_on_close(first.clone(), close_cb(record_close));
1256        assert!(m.modal_state.on_close.is_some());
1257        assert_eq!(
1258            m.modal_state.on_close.as_ref().unwrap().callback,
1259            close_cb(record_close)
1260        );
1261
1262        let second = RefAny::new(2u32);
1263        m.set_on_close(second.clone(), close_cb(close_do_nothing));
1264        let stored = m.modal_state.on_close.as_ref().expect("still set");
1265        assert_eq!(stored.callback, close_cb(close_do_nothing), "last write wins");
1266        assert_eq!(stored.refany, second, "the payload is replaced too");
1267        assert_ne!(stored.refany, first);
1268    }
1269
1270    #[test]
1271    fn with_on_close_matches_set_on_close_and_keeps_everything_else() {
1272        let data = RefAny::new(7u64);
1273
1274        let built = Modal::create(Dom::create_div())
1275            .with_title(AzString::from("t"))
1276            .with_open(true)
1277            .with_on_close(data.clone(), close_cb(record_close));
1278
1279        let mut mutated = Modal::create(Dom::create_div())
1280            .with_title(AzString::from("t"))
1281            .with_open(true);
1282        mutated.set_on_close(data.clone(), close_cb(record_close));
1283
1284        assert_eq!(built, mutated);
1285        assert_eq!(built.title.as_str(), "t");
1286        assert!(built.modal_state.inner.open);
1287        assert!(built.show_close_button);
1288    }
1289
1290    #[test]
1291    fn set_on_close_does_not_switch_the_close_button_on() {
1292        // Unlike `Alert::set_on_dismiss`, this setter does NOT imply a close
1293        // button — a caller that turned the "x" off gets a callback that can
1294        // never fire, and `dom()` silently drops it.
1295        let data = RefAny::new(CloseLog { calls: Vec::new() });
1296        let m = Modal::create(Dom::create_div())
1297            .with_close_button(false)
1298            .with_on_close(data.clone(), close_cb(record_close));
1299
1300        assert!(m.modal_state.on_close.is_some(), "the callback is stored");
1301        assert!(!m.show_close_button, "but the button stays off");
1302        assert_eq!(count_callbacks(&m.dom()), 0, "so nothing is wired into the DOM");
1303    }
1304
1305    // ------------------------------------------------------------------
1306    // Modal::swap_with_default
1307    // ------------------------------------------------------------------
1308
1309    #[test]
1310    fn swap_with_default_returns_the_original_and_resets_self() {
1311        let content = Dom::create_div().with_child(Dom::create_text("body"));
1312        let mut m = Modal::create(content.clone())
1313            .with_title(AzString::from("Title"))
1314            .with_open(true)
1315            .with_close_button(false);
1316
1317        let old = m.swap_with_default();
1318
1319        assert_eq!(old.title.as_str(), "Title");
1320        assert_eq!(old.content, content);
1321        assert!(old.modal_state.inner.open);
1322        assert!(!old.show_close_button);
1323
1324        assert_eq!(m, Modal::default(), "self must be a pristine modal");
1325        assert_eq!(m.title.as_str(), "");
1326        assert!(!m.modal_state.inner.open);
1327        assert!(m.show_close_button);
1328        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::None));
1329    }
1330
1331    #[test]
1332    fn swap_with_default_is_stable_when_repeated() {
1333        let mut m = Modal::create(Dom::create_div()).with_title(AzString::from("t"));
1334        let _first = m.swap_with_default();
1335        let second = m.swap_with_default();
1336        assert_eq!(second, Modal::default());
1337        assert_eq!(m, Modal::default());
1338    }
1339
1340    #[test]
1341    fn swap_with_default_moves_the_callback_out_of_self() {
1342        let data = RefAny::new(0u8);
1343        let mut m = Modal::create(Dom::create_div())
1344            .with_on_close(data.clone(), close_cb(record_close));
1345
1346        let old = m.swap_with_default();
1347
1348        assert!(old.modal_state.on_close.is_some(), "the callback moves out");
1349        assert!(m.modal_state.on_close.is_none(), "and must not stay behind");
1350    }
1351
1352    // ------------------------------------------------------------------
1353    // Modal::dom
1354    // ------------------------------------------------------------------
1355
1356    #[test]
1357    fn dom_shape_is_backdrop_panel_close_content() {
1358        let dom = Modal::create(Dom::create_div()).dom();
1359
1360        assert!(has_class(&dom, "__azul-native-modal"));
1361        let kids = panel_kids(&dom);
1362        assert_eq!(kids.len(), 2, "no title -> [close, content]");
1363        assert!(has_class(&kids[0], "__azul-native-modal-close"));
1364        assert!(has_class(&kids[1], "__azul-native-modal-content"));
1365        assert_eq!(node_count(&dom), 5);
1366    }
1367
1368    #[test]
1369    fn dom_with_a_title_puts_the_close_button_first() {
1370        // document order is [close, title, content]: the "x" is absolutely
1371        // positioned, so it may come first without affecting the layout.
1372        let dom = Modal::create(Dom::create_div())
1373            .with_title(AzString::from("Title"))
1374            .dom();
1375
1376        let kids = panel_kids(&dom);
1377        assert_eq!(kids.len(), 3);
1378        assert!(has_class(&kids[0], "__azul-native-modal-close"));
1379        assert!(has_class(&kids[1], "__azul-native-modal-title"));
1380        assert!(has_class(&kids[2], "__azul-native-modal-content"));
1381        assert_eq!(text_of(&kids[1]), Some("Title"));
1382        assert_eq!(node_count(&dom), 6);
1383    }
1384
1385    #[test]
1386    fn dom_close_button_is_a_focusable_multiplication_sign_with_one_mouseup_handler() {
1387        let dom = Modal::create(Dom::create_div()).dom();
1388        let close = find_class(&dom, "__azul-native-modal-close").expect("close node");
1389
1390        assert_eq!(
1391            text_of(close),
1392            Some("\u{00D7}"),
1393            "the glyph must be U+00D7 MULTIPLICATION SIGN, not ASCII 'x'"
1394        );
1395        assert_eq!(close.root.get_tab_index(), Some(TabIndex::Auto));
1396
1397        let cbs = close.root.get_callbacks();
1398        assert_eq!(cbs.as_ref().len(), 1, "exactly one handler on the 'x'");
1399        let entry = &cbs.as_ref()[0];
1400        assert_eq!(
1401            entry.event,
1402            EventFilter::Hover(HoverEventFilter::MouseUp),
1403            "the modal closes on mouse-up, not mouse-down"
1404        );
1405        assert_eq!(entry.callback.cb, on_modal_close as usize);
1406        assert_eq!(count_callbacks(&dom), 1, "and nowhere else in the tree");
1407    }
1408
1409    #[test]
1410    fn dom_hands_a_snapshot_of_the_modal_state_to_the_close_button() {
1411        for open in [true, false] {
1412            let dom = Modal::create(Dom::create_div()).with_open(open).dom();
1413            let close = find_class(&dom, "__azul-native-modal-close").expect("close node");
1414            let mut payload = close.root.get_callbacks().as_ref()[0].refany.clone();
1415            assert_eq!(
1416                wrapper_open(&mut payload),
1417                open,
1418                "the payload must carry the open state at build time"
1419            );
1420        }
1421    }
1422
1423    #[test]
1424    fn dom_payloads_of_two_clones_are_independent() {
1425        // the payload is a *snapshot*: flipping one rendered modal's state must
1426        // not reach through to another render of the same builder
1427        let m = Modal::create(Dom::create_div()).with_open(true);
1428        let a = m.clone().dom();
1429        let b = m.dom();
1430
1431        let mut pa = find_class(&a, "__azul-native-modal-close")
1432            .expect("close")
1433            .root
1434            .get_callbacks()
1435            .as_ref()[0]
1436            .refany
1437            .clone();
1438        let mut pb = find_class(&b, "__azul-native-modal-close")
1439            .expect("close")
1440            .root
1441            .get_callbacks()
1442            .as_ref()[0]
1443            .refany
1444            .clone();
1445
1446        assert_ne!(pa, pb, "two renders must not share one RefAny allocation");
1447        let (_update, _changes) = run_close(Some(StyledDom::create_from_dom(a)), 2, pa.clone());
1448        assert!(!wrapper_open(&mut pa));
1449        assert!(wrapper_open(&mut pb), "the other render must be untouched");
1450    }
1451
1452    #[test]
1453    fn dom_applies_the_static_styles_verbatim() {
1454        let dom = Modal::create(Dom::create_div())
1455            .with_title(AzString::from("t"))
1456            .dom();
1457        let kids = panel_kids(&dom);
1458
1459        let want = |s: &[CssPropertyWithConditions]| {
1460            s.iter().map(|p| p.property.clone()).collect::<Vec<_>>()
1461        };
1462        assert_eq!(inline_props(panel(&dom)), want(MODAL_PANEL_STYLE));
1463        assert_eq!(inline_props(&kids[0]), want(MODAL_CLOSE_STYLE));
1464        assert_eq!(inline_props(&kids[1]), want(MODAL_TITLE_STYLE));
1465        assert_eq!(inline_props(&kids[2]), want(MODAL_CONTENT_STYLE));
1466    }
1467
1468    #[test]
1469    fn dom_backdrop_style_is_exactly_the_builders_backdrop_style() {
1470        for open in [true, false] {
1471            let m = Modal::create(Dom::create_div()).with_open(open);
1472            let expected = style_props(&m.backdrop_style);
1473            assert_eq!(inline_props(&m.dom()), expected);
1474        }
1475    }
1476
1477    #[test]
1478    fn panel_geometry_matches_the_documented_constants() {
1479        let props = style_props(&CssPropertyWithConditionsVec::from_const_slice(
1480            MODAL_PANEL_STYLE,
1481        ));
1482        for want in [
1483            CssProperty::const_min_width(LayoutMinWidth::const_px(PANEL_MIN_WIDTH)),
1484            CssProperty::const_max_width(LayoutMaxWidth::const_px(PANEL_MAX_WIDTH)),
1485            CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(
1486                PANEL_RADIUS,
1487            )),
1488            CssProperty::const_position(LayoutPosition::Relative),
1489            CssProperty::const_display(LayoutDisplay::Flex),
1490        ] {
1491            assert!(props.contains(&want), "panel style is missing {want:?}");
1492        }
1493        let (min, max) = (PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1494        assert!(min <= max, "min-width {min} must not exceed max-width {max}");
1495    }
1496
1497    #[test]
1498    fn every_static_style_declares_each_property_at_most_once() {
1499        for (name, style) in [
1500            ("panel", MODAL_PANEL_STYLE),
1501            ("title", MODAL_TITLE_STYLE),
1502            ("close", MODAL_CLOSE_STYLE),
1503            ("content", MODAL_CONTENT_STYLE),
1504        ] {
1505            let types = property_types(&CssPropertyWithConditionsVec::from_const_slice(style));
1506            for (i, a) in types.iter().enumerate() {
1507                for b in &types[i + 1..] {
1508                    assert_ne!(a, b, "{name}: the same property is declared twice");
1509                }
1510            }
1511            for p in style {
1512                assert!(
1513                    p.apply_if.as_ref().is_empty(),
1514                    "{name}: {:?} must be unconditional",
1515                    p.property
1516                );
1517            }
1518        }
1519    }
1520
1521    #[test]
1522    fn dom_survives_extreme_content_and_titles() {
1523        // deep nesting, a huge sibling list and an adversarial title at once
1524        let content = Dom::create_div().with_children(DomVec::from_vec(
1525            (0..2_000).map(|_| nested_content(4)).collect::<Vec<_>>(),
1526        ));
1527        let dom = Modal::create(content)
1528            .with_title(AzString::from("\u{202E}\u{1F469}\u{200D}\u{1F467}\0"))
1529            .with_open(true)
1530            .dom();
1531
1532        // backdrop + panel + close + title + wrapper + content root + 2000*5
1533        assert_eq!(node_count(&dom), 6 + 2_000 * 5);
1534        assert_eq!(count_callbacks(&dom), 1);
1535    }
1536
1537    #[test]
1538    fn from_modal_for_dom_equals_dom() {
1539        // no RefAny is involved once the close button is off, so the two renders
1540        // are value-comparable
1541        let m = Modal::create(Dom::create_div())
1542            .with_title(AzString::from("t"))
1543            .with_close_button(false)
1544            .with_open(true);
1545        assert_eq!(Dom::from(m.clone()), m.dom());
1546    }
1547
1548    #[test]
1549    fn two_renders_with_a_close_button_differ_only_in_the_refany_identity() {
1550        // RefAny equality is allocation identity, so two renders of the *same*
1551        // modal are NOT equal — but they are once the button (and its payload)
1552        // is gone.
1553        let m = Modal::create(Dom::create_div());
1554        assert_ne!(m.clone().dom(), m.clone().dom());
1555
1556        let q = m.with_close_button(false);
1557        assert_eq!(q.clone().dom(), q.dom());
1558    }
1559
1560    // ------------------------------------------------------------------
1561    // on_modal_close
1562    // ------------------------------------------------------------------
1563
1564    #[test]
1565    fn close_hides_the_backdrop_and_flips_open() {
1566        let mut data = RefAny::new(ModalStateWrapper {
1567            inner: ModalState { open: true },
1568            on_close: OptionModalOnClose::None,
1569        });
1570
1571        // node 2 == the close button; its parent is the panel(1), whose parent is
1572        // the backdrop(0)
1573        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data.clone());
1574
1575        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
1576        assert_eq!(
1577            display_writes(&changes),
1578            alloc::vec![(0usize, LayoutDisplay::None)],
1579            "the *backdrop* (not the panel or the button) must be hidden"
1580        );
1581        assert!(!wrapper_open(&mut data), "state must flip to closed");
1582    }
1583
1584    #[test]
1585    fn close_invokes_the_user_callback_with_the_already_flipped_state() {
1586        let mut log = RefAny::new(CloseLog { calls: Vec::new() });
1587        let mut data = RefAny::new(ModalStateWrapper {
1588            inner: ModalState { open: true },
1589            on_close: Some(ModalOnClose {
1590                callback: close_cb(record_close),
1591                refany: log.clone(),
1592            })
1593            .into(),
1594        });
1595
1596        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data.clone());
1597
1598        assert_eq!(update, Update::RefreshDom, "the user callback's Update is returned");
1599        assert_eq!(
1600            log_calls(&mut log),
1601            alloc::vec![false],
1602            "the callback must see `open == false` (already closed)"
1603        );
1604        assert!(!wrapper_open(&mut data));
1605        assert_eq!(
1606            display_writes(&changes),
1607            alloc::vec![(0usize, LayoutDisplay::None)],
1608            "the backdrop is hidden even after a user callback ran"
1609        );
1610    }
1611
1612    #[test]
1613    fn close_hides_the_backdrop_even_when_the_user_callback_does_nothing() {
1614        let data = RefAny::new(ModalStateWrapper {
1615            inner: ModalState { open: true },
1616            on_close: Some(ModalOnClose {
1617                callback: close_cb(close_do_nothing),
1618                refany: RefAny::new(0u8),
1619            })
1620            .into(),
1621        });
1622
1623        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data);
1624
1625        assert_eq!(update, Update::DoNothing);
1626        assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
1627    }
1628
1629    #[test]
1630    fn close_twice_is_idempotent() {
1631        let mut log = RefAny::new(CloseLog { calls: Vec::new() });
1632        let mut data = RefAny::new(ModalStateWrapper {
1633            inner: ModalState { open: true },
1634            on_close: Some(ModalOnClose {
1635                callback: close_cb(record_close),
1636                refany: log.clone(),
1637            })
1638            .into(),
1639        });
1640
1641        for _ in 0..2 {
1642            let (update, changes) = run_close(Some(modal_styled_dom()), 2, data.clone());
1643            assert_eq!(update, Update::RefreshDom);
1644            assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
1645        }
1646
1647        assert!(!wrapper_open(&mut data), "a second close must not re-open");
1648        assert_eq!(
1649            log_calls(&mut log),
1650            alloc::vec![false, false],
1651            "each click fires the callback exactly once, always with open == false"
1652        );
1653    }
1654
1655    #[test]
1656    fn close_on_the_panel_is_a_noop_because_the_backdrop_has_no_parent() {
1657        // hit node 1 == the panel: parent is the backdrop(0), which has no parent
1658        // of its own, so the handler bails *before* touching the state
1659        let mut data = RefAny::new(ModalStateWrapper {
1660            inner: ModalState { open: true },
1661            on_close: OptionModalOnClose::None,
1662        });
1663
1664        let (update, changes) = run_close(Some(modal_styled_dom()), 1, data.clone());
1665
1666        assert_eq!(update, Update::DoNothing);
1667        assert!(changes.is_empty(), "nothing may be restyled without a grandparent");
1668        assert!(wrapper_open(&mut data), "state must not flip");
1669    }
1670
1671    #[test]
1672    fn close_on_the_root_is_a_noop() {
1673        let mut data = RefAny::new(ModalStateWrapper {
1674            inner: ModalState { open: true },
1675            on_close: OptionModalOnClose::None,
1676        });
1677
1678        let (update, changes) = run_close(Some(modal_styled_dom()), 0, data.clone());
1679
1680        assert_eq!(update, Update::DoNothing);
1681        assert!(changes.is_empty());
1682        assert!(wrapper_open(&mut data));
1683    }
1684
1685    #[test]
1686    fn close_with_a_stale_hit_node_is_a_noop() {
1687        // node 999 does not exist in the 5-node fixture
1688        let mut data = RefAny::new(ModalStateWrapper {
1689            inner: ModalState { open: true },
1690            on_close: OptionModalOnClose::None,
1691        });
1692
1693        let (update, changes) = run_close(Some(modal_styled_dom()), 999, data.clone());
1694
1695        assert_eq!(update, Update::DoNothing);
1696        assert!(changes.is_empty());
1697        assert!(wrapper_open(&mut data));
1698    }
1699
1700    #[test]
1701    fn close_without_any_layout_result_is_a_noop() {
1702        let mut data = RefAny::new(ModalStateWrapper {
1703            inner: ModalState { open: true },
1704            on_close: OptionModalOnClose::None,
1705        });
1706
1707        let (update, changes) = run_close(None, 2, data.clone());
1708
1709        assert_eq!(update, Update::DoNothing);
1710        assert!(changes.is_empty());
1711        assert!(wrapper_open(&mut data), "state must not flip");
1712    }
1713
1714    #[test]
1715    fn close_with_a_foreign_payload_is_a_noop() {
1716        // the callback-bearing node carries a RefAny of the *wrong* type: the
1717        // downcast fails before the restyle, so nothing is hidden
1718        let data = RefAny::new(0xdead_beef_u64);
1719
1720        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data);
1721
1722        assert_eq!(update, Update::DoNothing);
1723        assert!(changes.is_empty(), "a foreign payload must not hide the backdrop");
1724    }
1725
1726    #[test]
1727    fn close_with_a_plain_modal_state_payload_is_a_noop() {
1728        // `ModalState` and `ModalStateWrapper` are different types — handing the
1729        // inner state alone must not be silently accepted
1730        let data = RefAny::new(ModalState { open: true });
1731
1732        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data);
1733
1734        assert_eq!(update, Update::DoNothing);
1735        assert!(changes.is_empty());
1736    }
1737
1738    #[test]
1739    fn close_hides_the_grandparent_whatever_it_is() {
1740        // The handler hides `parent(parent(hit))` unconditionally. On a deeper
1741        // tree that is NOT the root — documenting why the close button has to
1742        // stay a direct child of the panel.
1743        let deep = StyledDom::create_from_dom(nested_content(3));
1744        assert_eq!(deep.node_hierarchy.as_ref().len(), 4);
1745
1746        let data = RefAny::new(ModalStateWrapper {
1747            inner: ModalState { open: true },
1748            on_close: OptionModalOnClose::None,
1749        });
1750        let (update, changes) = run_close(Some(deep), 3, data);
1751
1752        assert_eq!(update, Update::DoNothing);
1753        assert_eq!(
1754            display_writes(&changes),
1755            alloc::vec![(1usize, LayoutDisplay::None)],
1756            "node 1 (the grandparent), not the root, gets hidden"
1757        );
1758    }
1759
1760    #[test]
1761    fn close_end_to_end_through_the_real_dom_payload() {
1762        // Take the *actual* RefAny the widget wired into its close button and
1763        // drive the *actual* handler the widget registered against it.
1764        let modal = Modal::create(Dom::create_text("body"))
1765            .with_title(AzString::from("Title"))
1766            .with_open(true);
1767        let dom = modal.dom();
1768
1769        let close = find_class(&dom, "__azul-native-modal-close").expect("close node");
1770        let entry = &close.root.get_callbacks().as_ref()[0];
1771        assert_eq!(entry.callback.cb, on_modal_close as usize);
1772        let mut payload = entry.refany.clone();
1773        assert!(wrapper_open(&mut payload), "the snapshot starts open");
1774
1775        let styled = StyledDom::create_from_dom(dom);
1776        // backdrop(0), panel(1), close(2), title(3), wrapper(4), content(5)
1777        assert_eq!(styled.node_hierarchy.as_ref().len(), 6);
1778
1779        let (update, changes) = run_close(Some(styled), 2, payload.clone());
1780
1781        assert_eq!(update, Update::DoNothing);
1782        assert_eq!(
1783            display_writes(&changes),
1784            alloc::vec![(0usize, LayoutDisplay::None)]
1785        );
1786        assert!(
1787            !wrapper_open(&mut payload),
1788            "the state living in the DOM must be flipped to closed"
1789        );
1790    }
1791
1792    #[test]
1793    fn close_end_to_end_invokes_a_user_callback_wired_through_the_builder() {
1794        let mut log = RefAny::new(CloseLog { calls: Vec::new() });
1795        let dom = Modal::create(Dom::create_div())
1796            .with_open(true)
1797            .with_on_close(log.clone(), close_cb(record_close))
1798            .dom();
1799
1800        let payload = find_class(&dom, "__azul-native-modal-close")
1801            .expect("close node")
1802            .root
1803            .get_callbacks()
1804            .as_ref()[0]
1805            .refany
1806            .clone();
1807
1808        let (update, changes) = run_close(Some(StyledDom::create_from_dom(dom)), 2, payload);
1809
1810        assert_eq!(update, Update::RefreshDom);
1811        assert_eq!(log_calls(&mut log), alloc::vec![false]);
1812        assert_eq!(
1813            display_writes(&changes),
1814            alloc::vec![(0usize, LayoutDisplay::None)]
1815        );
1816    }
1817
1818    // ------------------------------------------------------------------
1819    // ModalState / ModalStateWrapper invariants
1820    // ------------------------------------------------------------------
1821
1822    #[test]
1823    fn modal_state_defaults_to_closed_with_no_callback() {
1824        assert!(!ModalState::default().open);
1825        let w = ModalStateWrapper::default();
1826        assert!(!w.inner.open);
1827        assert!(w.on_close.is_none());
1828        assert_eq!(w, ModalStateWrapper::default());
1829    }
1830
1831    #[test]
1832    fn modal_state_is_copy_and_value_comparable() {
1833        let a = ModalState { open: true };
1834        let b = a; // Copy
1835        assert_eq!(a, b);
1836        assert_ne!(a, ModalState { open: false });
1837    }
1838}