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

            
36
use azul_core::{
37
    callbacks::{CoreCallback, CoreCallbackData, Update},
38
    dom::{Dom, DomVec, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
39
    refany::RefAny,
40
};
41
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
42
use 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

            
52
use crate::callbacks::{Callback, CallbackInfo};
53

            
54
static MODAL_BACKDROP_CLASS: &[IdOrClass] =
55
    &[Class(AzString::from_const_str("__azul-native-modal"))];
56
static MODAL_PANEL_CLASS: &[IdOrClass] =
57
    &[Class(AzString::from_const_str("__azul-native-modal-panel"))];
58
static MODAL_TITLE_CLASS: &[IdOrClass] =
59
    &[Class(AzString::from_const_str("__azul-native-modal-title"))];
60
static MODAL_CLOSE_CLASS: &[IdOrClass] =
61
    &[Class(AzString::from_const_str("__azul-native-modal-close"))];
62
static MODAL_CONTENT_CLASS: &[IdOrClass] =
63
    &[Class(AzString::from_const_str("__azul-native-modal-content"))];
64

            
65
const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
66
const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
67
const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
68
    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
69

            
70
// ---- layout (logical px) ----
71
const PANEL_MIN_WIDTH: isize = 280;
72
const PANEL_MAX_WIDTH: isize = 520;
73
const PANEL_RADIUS: isize = 8;
74

            
75
// ---- colours ----
76
/// Semi-transparent black backdrop (rgba(0,0,0,0.5)).
77
const BACKDROP_COLOR: ColorU = ColorU { r: 0, g: 0, b: 0, a: 128 };
78
const PANEL_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
79
const PANEL_BORDER_COLOR: ColorU = ColorU { r: 204, g: 204, b: 204, a: 255 }; // #cccccc
80
const TITLE_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 }; // #212529
81
const 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.
85
pub type ModalOnCloseCallbackType = extern "C" fn(RefAny, CallbackInfo, ModalState) -> Update;
86
impl_widget_callback!(
87
    ModalOnClose,
88
    OptionModalOnClose,
89
    ModalOnCloseCallback,
90
    ModalOnCloseCallbackType
91
);
92

            
93
azul_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)]
110
pub 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)]
125
pub 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)]
135
pub 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).
143
160
fn build_backdrop_style(open: bool) -> CssPropertyWithConditionsVec {
144
160
    let display = if open {
145
35
        LayoutDisplay::Flex
146
    } else {
147
125
        LayoutDisplay::None
148
    };
149
160
    let bg_vec = StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(
150
160
        BACKDROP_COLOR
151
160
    )]);
152
160
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
153
160
        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
154
160
        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
155
160
        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(0))),
156
160
        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
160
        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::Px(
160
160
            PixelValue::const_percent(100),
161
160
        ))),
162
160
        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::Px(
163
160
            PixelValue::const_percent(100),
164
160
        ))),
165
        // Centre the panel.
166
160
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
167
160
            LayoutFlexDirection::Row,
168
        )),
169
160
        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
170
160
            LayoutJustifyContent::Center,
171
        )),
172
160
        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
173
160
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
174
160
        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
175
    ])
176
160
}
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).
182
static 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".
273
static 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.
291
static 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.
304
static 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

            
309
impl Modal {
310
    /// Creates a new (closed) modal holding `content`, with a "x" close button and
311
    /// no title.
312
108
    #[must_use] pub fn create(content: Dom) -> Self {
313
108
        Self {
314
108
            modal_state: ModalStateWrapper::default(),
315
108
            title: AzString::from_const_str(""),
316
108
            content,
317
108
            show_close_button: true,
318
108
            backdrop_style: build_backdrop_style(false),
319
108
        }
320
108
    }
321

            
322
    /// Sets the dialog title (empty = no title).
323
    #[inline]
324
59
    pub fn set_title(&mut self, title: AzString) {
325
59
        self.title = title;
326
59
    }
327

            
328
    /// Builder-style setter for the title.
329
    #[inline]
330
41
    #[must_use] pub fn with_title(mut self, title: AzString) -> Self {
331
41
        self.set_title(title);
332
41
        self
333
41
    }
334

            
335
    /// Replaces the content shown inside the panel.
336
    #[inline]
337
3
    pub fn set_content(&mut self, content: Dom) {
338
3
        self.content = content;
339
3
    }
340

            
341
    /// Builder-style setter for the content.
342
    #[inline]
343
1
    #[must_use] pub fn with_content(mut self, content: Dom) -> Self {
344
1
        self.set_content(content);
345
1
        self
346
1
    }
347

            
348
    /// Sets whether the dialog is currently open, recomputing the backdrop style.
349
    #[inline]
350
34
    pub fn set_open(&mut self, open: bool) {
351
34
        self.modal_state.inner.open = open;
352
34
        self.backdrop_style = build_backdrop_style(open);
353
34
    }
354

            
355
    /// Builder-style setter for the initial open state.
356
    #[inline]
357
22
    #[must_use] pub fn with_open(mut self, open: bool) -> Self {
358
22
        self.set_open(open);
359
22
        self
360
22
    }
361

            
362
    /// Sets whether the "x" close button is shown.
363
    #[inline]
364
14
    pub const fn set_close_button(&mut self, show: bool) {
365
14
        self.show_close_button = show;
366
14
    }
367

            
368
    /// Builder-style setter for the close-button flag.
369
    #[inline]
370
9
    #[must_use] pub const fn with_close_button(mut self, show: bool) -> Self {
371
9
        self.set_close_button(show);
372
9
        self
373
9
    }
374

            
375
    /// Sets the close callback (invoked with the new state when "x" is clicked).
376
    #[inline]
377
7
    pub fn set_on_close<C: Into<ModalOnCloseCallback>>(&mut self, data: RefAny, on_close: C) {
378
7
        self.modal_state.on_close = Some(ModalOnClose {
379
7
            callback: on_close.into(),
380
7
            refany: data,
381
7
        })
382
7
        .into();
383
7
    }
384

            
385
    /// Builder-style setter for the close callback.
386
    #[inline]
387
4
    #[must_use] pub fn with_on_close<C: Into<ModalOnCloseCallback>>(
388
4
        mut self,
389
4
        data: RefAny,
390
4
        on_close: C,
391
4
    ) -> Self {
392
4
        self.set_on_close(data, on_close);
393
4
        self
394
4
    }
395

            
396
    /// Replaces `self` with a default (empty, closed) modal and returns the original.
397
    #[inline]
398
4
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
399
4
        let mut s = Self::create(Dom::default());
400
4
        core::mem::swap(&mut s, self);
401
4
        s
402
4
    }
403

            
404
    /// Renders the modal into a [`Dom`] subtree with the `__azul-native-modal`
405
    /// class (the backdrop).
406
47
    #[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
47
        let mut panel_children = Vec::new();
411

            
412
47
        if self.show_close_button {
413
41
            let close = Dom::create_p_with_text(AzString::from_const_str("\u{00D7}"))
414
41
                .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_CLOSE_CLASS))
415
41
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_CLOSE_STYLE))
416
41
                .with_tab_index(TabIndex::Auto)
417
41
                .with_callbacks(
418
41
                    alloc::vec![CoreCallbackData {
419
41
                        event: azul_core::dom::EventFilter::Hover(
420
41
                            azul_core::dom::HoverEventFilter::MouseUp,
421
41
                        ),
422
41
                        callback: CoreCallback {
423
41
                            cb: on_modal_close as usize,
424
41
                            ctx: azul_core::refany::OptionRefAny::None,
425
41
                        },
426
41
                        refany: RefAny::new(self.modal_state),
427
41
                    }]
428
41
                    .into(),
429
41
                );
430
41
            panel_children.push(close);
431
41
        }
432

            
433
47
        if !self.title.as_str().is_empty() {
434
19
            let title = Dom::create_p_with_text(self.title)
435
19
                .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_TITLE_CLASS))
436
19
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_TITLE_STYLE));
437
19
            panel_children.push(title);
438
28
        }
439

            
440
47
        let content = Dom::create_div()
441
47
            .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_CONTENT_CLASS))
442
47
            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_CONTENT_STYLE))
443
47
            .with_children(DomVec::from_vec(alloc::vec![self.content]));
444
47
        panel_children.push(content);
445

            
446
47
        let panel = Dom::create_div()
447
47
            .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_PANEL_CLASS))
448
47
            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(MODAL_PANEL_STYLE))
449
47
            .with_children(DomVec::from_vec(panel_children));
450

            
451
47
        Dom::create_div()
452
47
            .with_ids_and_classes(IdOrClassVec::from_const_slice(MODAL_BACKDROP_CLASS))
453
47
            .with_css_props(self.backdrop_style)
454
47
            .with_children(DomVec::from_vec(alloc::vec![panel]))
455
47
    }
456
}
457

            
458
impl Default for Modal {
459
7
    fn default() -> Self {
460
7
        Self::create(Dom::default())
461
7
    }
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`.
469
15
extern "C" fn on_modal_close(mut data: RefAny, mut info: CallbackInfo) -> Update {
470
15
    let close_node = info.get_hit_node();
471
15
    let Some(panel) = info.get_parent(close_node) else {
472
3
        return Update::DoNothing;
473
    };
474
12
    let Some(backdrop) = info.get_parent(panel) else {
475
1
        return Update::DoNothing;
476
    };
477

            
478
9
    let result = {
479
11
        let Some(mut modal) = data.downcast_mut::<ModalStateWrapper>() else {
480
2
            return Update::DoNothing;
481
        };
482
9
        modal.inner.open = false;
483
9
        let inner = modal.inner;
484
9
        let modal = &mut *modal;
485
9
        match modal.on_close.as_mut() {
486
5
            Some(ModalOnClose { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
487
4
            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
9
    info.set_css_property(backdrop, CssProperty::const_display(LayoutDisplay::None));
495

            
496
9
    result
497
15
}
498

            
499
impl From<Modal> for Dom {
500
1
    fn from(m: Modal) -> Self {
501
1
        m.dom()
502
1
    }
503
}
504

            
505
#[cfg(test)]
506
mod 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, Class(s) if s.as_str() == name))
561
    }
562

            
563
    /// The text of a text node, looking through the `<p>` block wrapper the
564
    /// label convention mandates (`p > text`).
565
    fn text_of(node: &Dom) -> Option<&str> {
566
        match node.root.get_node_type() {
567
            NodeType::Text(s) => Some(s.as_ref().as_str()),
568
            NodeType::P => match node.children.as_ref() {
569
                [only] => match only.root.get_node_type() {
570
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
571
                    _ => None,
572
                },
573
                _ => None,
574
            },
575
            _ => None,
576
        }
577
    }
578

            
579
    /// The properties of a style vec, in declaration order.
580
    fn style_props(style: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
581
        style.as_ref().iter().map(|p| p.property.clone()).collect()
582
    }
583

            
584
    /// The *kind* of every declared property, in order (ignores the values).
585
    fn property_types(
586
        style: &CssPropertyWithConditionsVec,
587
    ) -> Vec<core::mem::Discriminant<CssProperty>> {
588
        style
589
            .as_ref()
590
            .iter()
591
            .map(|p| core::mem::discriminant(&p.property))
592
            .collect()
593
    }
594

            
595
    /// A node's *inline* style properties, in declaration order.
596
    fn inline_props(node: &Dom) -> Vec<CssProperty> {
597
        node.root
598
            .style
599
            .iter_inline_properties()
600
            .map(|(p, _)| p.clone())
601
            .collect()
602
    }
603

            
604
    /// The `display` value declared in a style vec.
605
    fn display_of(style: &CssPropertyWithConditionsVec) -> Option<LayoutDisplay> {
606
        style.as_ref().iter().find_map(|p| match &p.property {
607
            CssProperty::Display(v) => v.get_property().copied(),
608
            _ => None,
609
        })
610
    }
611

            
612
    /// The `display` value in a node's *inline* style.
613
    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
614
        node.root
615
            .style
616
            .iter_inline_properties()
617
            .find_map(|(p, _)| match p {
618
                CssProperty::Display(v) => v.get_property().copied(),
619
                _ => None,
620
            })
621
    }
622

            
623
    /// The `background-color` of a style vec (first background layer only).
624
    fn background_color(style: &CssPropertyWithConditionsVec) -> Option<ColorU> {
625
        style.as_ref().iter().find_map(|p| match &p.property {
626
            CssProperty::BackgroundContent(v) => match v.get_property()?.as_ref().first()? {
627
                StyleBackgroundContent::Color(c) => Some(*c),
628
                _ => None,
629
            },
630
            _ => None,
631
        })
632
    }
633

            
634
    /// The one and only child of the backdrop: the panel.
635
    fn panel(dom: &Dom) -> &Dom {
636
        let kids = dom.children.as_ref();
637
        assert_eq!(kids.len(), 1, "the backdrop must have exactly one child");
638
        assert!(
639
            has_class(&kids[0], "__azul-native-modal-panel"),
640
            "the backdrop's only child must be the panel"
641
        );
642
        &kids[0]
643
    }
644

            
645
    fn panel_kids(dom: &Dom) -> &[Dom] {
646
        panel(dom).children.as_ref()
647
    }
648

            
649
    /// The first node in the tree carrying `class` (pre-order).
650
    fn find_class<'a>(dom: &'a Dom, class: &str) -> Option<&'a Dom> {
651
        if has_class(dom, class) {
652
            return Some(dom);
653
        }
654
        dom.children
655
            .as_ref()
656
            .iter()
657
            .find_map(|c| find_class(c, class))
658
    }
659

            
660
    /// Total number of nodes in a `Dom` tree.
661
    fn node_count(dom: &Dom) -> usize {
662
        1 + dom
663
            .children
664
            .as_ref()
665
            .iter()
666
            .map(node_count)
667
            .sum::<usize>()
668
    }
669

            
670
    /// Total number of callbacks registered anywhere in a `Dom` tree.
671
    fn count_callbacks(dom: &Dom) -> usize {
672
        dom.root.get_callbacks().as_ref().len()
673
            + dom
674
                .children
675
                .as_ref()
676
                .iter()
677
                .map(count_callbacks)
678
                .sum::<usize>()
679
    }
680

            
681
    /// A `depth`-deep chain of nested divs (adversarial content).
682
    fn nested_content(depth: usize) -> Dom {
683
        let mut d = Dom::create_div();
684
        for _ in 0..depth {
685
            d = Dom::create_div().with_child(d);
686
        }
687
        d
688
    }
689

            
690
    /// The exact backdrop style the widget documents, for a given `display`.
691
    fn expected_backdrop(display: LayoutDisplay) -> Vec<CssProperty> {
692
        alloc::vec![
693
            CssProperty::const_display(display),
694
            CssProperty::const_position(LayoutPosition::Absolute),
695
            CssProperty::const_top(LayoutTop::const_px(0)),
696
            CssProperty::const_left(LayoutLeft::const_px(0)),
697
            CssProperty::const_width(LayoutWidth::Px(PixelValue::const_percent(100))),
698
            CssProperty::const_height(LayoutHeight::Px(PixelValue::const_percent(100))),
699
            CssProperty::const_flex_direction(LayoutFlexDirection::Row),
700
            CssProperty::const_justify_content(LayoutJustifyContent::Center),
701
            CssProperty::const_align_items(LayoutAlignItems::Center),
702
            CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0)),
703
            CssProperty::const_background_content(StyleBackgroundContentVec::from_vec(
704
                alloc::vec![StyleBackgroundContent::Color(BACKDROP_COLOR)]
705
            )),
706
        ]
707
    }
708

            
709
    /// A `RefAny` payload recording every `ModalState` a user `on_close` sees.
710
    struct CloseLog {
711
        calls: Vec<bool>,
712
    }
713

            
714
    extern "C" fn record_close(mut data: RefAny, _: CallbackInfo, state: ModalState) -> Update {
715
        if let Some(mut log) = data.downcast_mut::<CloseLog>() {
716
            log.calls.push(state.open);
717
        }
718
        Update::RefreshDom
719
    }
720

            
721
    extern "C" fn close_do_nothing(_: RefAny, _: CallbackInfo, _: ModalState) -> Update {
722
        Update::DoNothing
723
    }
724

            
725
    fn close_cb(f: ModalOnCloseCallbackType) -> ModalOnCloseCallback {
726
        f.into()
727
    }
728

            
729
    /// `open` of a `ModalStateWrapper` payload.
730
    fn wrapper_open(data: &mut RefAny) -> bool {
731
        data.downcast_ref::<ModalStateWrapper>()
732
            .expect("payload must still be a ModalStateWrapper")
733
            .inner
734
            .open
735
    }
736

            
737
    /// The `open` flags recorded by a `CloseLog` payload.
738
    fn log_calls(data: &mut RefAny) -> Vec<bool> {
739
        data.downcast_ref::<CloseLog>()
740
            .expect("payload must still be a CloseLog")
741
            .calls
742
            .clone()
743
    }
744

            
745
    /// A `DomLayoutResult` with an *empty* layout tree: the close handler only
746
    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
747
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
748
        DomLayoutResult {
749
            styled_dom,
750
            layout_tree: LayoutTree {
751
                nodes: Vec::new(),
752
                warm: Vec::new(),
753
                cold: Vec::new(),
754
                root: 0,
755
                dom_to_layout: BTreeMap::new(),
756
                children_arena: Vec::new(),
757
                children_offsets: Vec::new(),
758
                subtree_needs_intrinsic: Vec::new(),
759
            },
760
            calculated_positions: Vec::new(),
761
            viewport: LogicalRect::zero(),
762
            display_list: Arc::new(DisplayList::default()),
763
            scroll_ids: HashMap::new(),
764
            scroll_id_to_node_id: HashMap::new(),
765
        }
766
    }
767

            
768
    /// The flattened DOM of a default modal: `backdrop(0)`, `panel(1)`,
769
    /// `close <p>(2)`, `close text(3)`, `content-wrapper(4)`, `content(5)` —
770
    /// i.e. exactly the hierarchy `on_modal_close` walks (hit node -> parent ->
771
    /// parent). The callback lives on the `<p>`, never on the text node.
772
    fn modal_styled_dom() -> StyledDom {
773
        let styled = StyledDom::create_from_dom(Modal::create(Dom::create_div()).dom());
774
        assert_eq!(
775
            styled.node_hierarchy.as_ref().len(),
776
            6,
777
            "fixture must flatten to backdrop/panel/close <p> + text/wrapper/content"
778
        );
779
        styled
780
    }
781

            
782
    /// Invokes `on_modal_close` against a `LayoutWindow` holding `styled` (or
783
    /// nothing at all, when `styled` is `None`), with `hit` as the hit node.
784
    /// Returns the `Update` plus every recorded `CallbackChange`.
785
    fn run_close(
786
        styled: Option<StyledDom>,
787
        hit: usize,
788
        data: RefAny,
789
    ) -> (Update, Vec<CallbackChange>) {
790
        let mut layout_window =
791
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
792
        if let Some(sd) = styled {
793
            layout_window
794
                .layout_results
795
                .insert(DomId::ROOT_ID, layout_result(sd));
796
        }
797

            
798
        let renderer_resources = RendererResources::default();
799
        let previous_window_state: Option<FullWindowState> = None;
800
        let current_window_state = FullWindowState::default();
801
        let gl_context = OptionGlContextPtr::None;
802
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
803
            BTreeMap::new();
804
        let window_handle = RawWindowHandle::Unsupported;
805
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
806

            
807
        let ref_data = CallbackInfoRefData {
808
            layout_window: &layout_window,
809
            renderer_resources: &renderer_resources,
810
            previous_window_state: &previous_window_state,
811
            current_window_state: &current_window_state,
812
            gl_context: &gl_context,
813
            current_scroll_manager: &scroll_states,
814
            current_window_handle: &window_handle,
815
            system_callbacks: &system_callbacks,
816
            system_style: Arc::new(SystemStyle::default()),
817
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
818
            #[cfg(feature = "icu")]
819
            icu_localizer: IcuLocalizerHandle::default(),
820
            ctx: OptionRefAny::None,
821
        };
822

            
823
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
824

            
825
        let info = CallbackInfo::new(
826
            &ref_data,
827
            &changes,
828
            DomNodeId {
829
                dom: DomId::ROOT_ID,
830
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
831
            },
832
            OptionLogicalPosition::None,
833
            OptionLogicalPosition::None,
834
        );
835

            
836
        let update = on_modal_close(data, info);
837
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
838
        (update, recorded)
839
    }
840

            
841
    /// Every `display` write recorded in the change log, as `(node index, display)`.
842
    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
843
        let mut out = Vec::new();
844
        for change in changes {
845
            if let CallbackChange::ChangeNodeCssProperties {
846
                node_id, properties, ..
847
            } = change
848
            {
849
                for p in properties.as_ref() {
850
                    if let CssProperty::Display(v) = p {
851
                        if let Some(d) = v.get_property() {
852
                            out.push((node_id.index(), *d));
853
                        }
854
                    }
855
                }
856
            }
857
        }
858
        out
859
    }
860

            
861
    // ------------------------------------------------------------------
862
    // build_backdrop_style
863
    // ------------------------------------------------------------------
864

            
865
    #[test]
866
    fn build_backdrop_style_emits_the_documented_property_list() {
867
        assert_eq!(
868
            style_props(&build_backdrop_style(true)),
869
            expected_backdrop(LayoutDisplay::Flex)
870
        );
871
        assert_eq!(
872
            style_props(&build_backdrop_style(false)),
873
            expected_backdrop(LayoutDisplay::None)
874
        );
875
    }
876

            
877
    #[test]
878
    fn build_backdrop_style_differs_only_in_the_display() {
879
        // The doc comment promises the toggle has *everything* it needs: both
880
        // variants must declare the same properties, in the same order, with the
881
        // same values — except `display`.
882
        let open = style_props(&build_backdrop_style(true));
883
        let closed = style_props(&build_backdrop_style(false));
884

            
885
        assert_eq!(open.len(), closed.len());
886
        let diffs: Vec<usize> = (0..open.len()).filter(|i| open[*i] != closed[*i]).collect();
887
        assert_eq!(diffs, alloc::vec![0usize], "only entry 0 (display) may differ");
888

            
889
        assert_eq!(display_of(&build_backdrop_style(true)), Some(LayoutDisplay::Flex));
890
        assert_eq!(display_of(&build_backdrop_style(false)), Some(LayoutDisplay::None));
891
    }
892

            
893
    #[test]
894
    fn build_backdrop_style_declares_no_property_twice() {
895
        // a duplicated property would silently shadow the earlier declaration —
896
        // and would make the runtime `display` toggle ambiguous
897
        for open in [true, false] {
898
            let types = property_types(&build_backdrop_style(open));
899
            for (i, a) in types.iter().enumerate() {
900
                for b in &types[i + 1..] {
901
                    assert_ne!(a, b, "open={open}: the backdrop declares the same property twice");
902
                }
903
            }
904
        }
905
    }
906

            
907
    #[test]
908
    fn build_backdrop_style_is_unconditional() {
909
        // nothing may be gated behind :hover/@media/... or the closed modal could
910
        // become visible under the wrong conditions
911
        for open in [true, false] {
912
            for p in build_backdrop_style(open).as_ref() {
913
                assert!(
914
                    p.apply_if.as_ref().is_empty(),
915
                    "open={open}: {:?} must be unconditional",
916
                    p.property
917
                );
918
            }
919
        }
920
    }
921

            
922
    #[test]
923
    fn build_backdrop_style_is_pure_and_repeatable() {
924
        for open in [true, false] {
925
            assert_eq!(build_backdrop_style(open), build_backdrop_style(open));
926
        }
927
        assert_ne!(build_backdrop_style(true), build_backdrop_style(false));
928
    }
929

            
930
    #[test]
931
    fn build_backdrop_style_dims_with_half_transparent_black() {
932
        for open in [true, false] {
933
            let style = build_backdrop_style(open);
934
            assert_eq!(
935
                background_color(&style),
936
                Some(ColorU { r: 0, g: 0, b: 0, a: 128 }),
937
                "the backdrop must stay rgba(0,0,0,0.5) in both states"
938
            );
939
        }
940
        // a fully opaque or fully transparent backdrop would be a regression
941
        let alpha = BACKDROP_COLOR.a;
942
        assert!(alpha > 0 && alpha < 255, "backdrop alpha {alpha} must dim, not erase");
943
    }
944

            
945
    // ------------------------------------------------------------------
946
    // Modal::create / Default
947
    // ------------------------------------------------------------------
948

            
949
    #[test]
950
    fn create_is_a_closed_modal_with_a_close_button_and_no_title() {
951
        let content = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("hi"));
952
        let m = Modal::create(content.clone());
953

            
954
        assert!(!m.modal_state.inner.open, "a fresh modal must start closed");
955
        assert!(m.modal_state.on_close.is_none());
956
        assert_eq!(m.title.as_str(), "");
957
        assert_eq!(m.content, content);
958
        assert!(m.show_close_button, "the 'x' is on by default");
959
        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::None));
960
    }
961

            
962
    #[test]
963
    fn default_equals_create_with_a_default_dom() {
964
        assert_eq!(Modal::default(), Modal::create(Dom::default()));
965
        assert_eq!(Modal::default(), Modal::default());
966
        assert!(!Modal::default().modal_state.inner.open);
967
    }
968

            
969
    #[test]
970
    fn create_survives_extreme_content() {
971
        // deeply nested, very wide, and empty content must all be stored verbatim
972
        assert_eq!(node_count(&Modal::create(nested_content(200)).content), 201);
973

            
974
        let wide = Dom::create_div().with_children(DomVec::from_vec(
975
            (0..5_000).map(|_| Dom::create_div()).collect::<Vec<_>>(),
976
        ));
977
        assert_eq!(node_count(&Modal::create(wide).content), 5_001);
978

            
979
        // a modal whose content is *another* modal's DOM
980
        let inner = Modal::create(Dom::create_div()).with_title(AzString::from("inner"));
981
        let nested = Modal::create(inner.dom());
982
        assert_eq!(nested.content.children.as_ref().len(), 1);
983
    }
984

            
985
    #[test]
986
    fn create_is_clone_and_value_comparable() {
987
        let m = Modal::create(Dom::create_div()).with_title(AzString::from("t"));
988
        assert_eq!(m.clone(), m);
989
        assert_ne!(m, Modal::create(Dom::create_div()));
990
    }
991

            
992
    // ------------------------------------------------------------------
993
    // Modal::set_title / with_title
994
    // ------------------------------------------------------------------
995

            
996
    #[test]
997
    fn set_title_stores_every_adversarial_string_byte_for_byte() {
998
        for s in ADVERSARIAL_TEXT {
999
            let mut m = Modal::create(Dom::create_div());
            m.set_title(AzString::from(s));
            assert_eq!(m.title.as_str(), s, "title {s:?} must round-trip unchanged");
            assert_eq!(m.title.as_str().len(), s.len(), "no normalisation may happen");
        }
    }
    #[test]
    fn set_title_survives_a_100k_char_title() {
        let long = "ä\u{0301}".repeat(50_000);
        let mut m = Modal::create(Dom::create_div());
        m.set_title(AzString::from(long.as_str()));
        assert_eq!(m.title.as_str(), long);
        let dom = m.dom();
        assert_eq!(
            text_of(&panel_kids(&dom)[1]),
            Some(long.as_str()),
            "the huge title must reach the DOM intact"
        );
    }
    #[test]
    fn with_title_matches_set_title_and_last_write_wins() {
        for s in ADVERSARIAL_TEXT {
            let built = Modal::create(Dom::create_div()).with_title(AzString::from(s));
            let mut mutated = Modal::create(Dom::create_div());
            mutated.set_title(AzString::from(s));
            assert_eq!(built, mutated);
        }
        let m = Modal::create(Dom::create_div())
            .with_title(AzString::from("first"))
            .with_title(AzString::from("second"))
            .with_title(AzString::from(""));
        assert_eq!(m.title.as_str(), "", "the last write must win, even an empty one");
    }
    #[test]
    fn set_title_touches_nothing_else() {
        let content = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("body"));
        let mut m = Modal::create(content.clone()).with_open(true);
        let before = m.backdrop_style.clone();
        m.set_title(AzString::from("Title"));
        assert!(m.modal_state.inner.open, "the title must not close the dialog");
        assert_eq!(m.backdrop_style, before, "the title must not rebuild the backdrop");
        assert_eq!(m.content, content);
        assert!(m.show_close_button);
    }
    #[test]
    fn only_a_byte_empty_title_suppresses_the_title_node() {
        for s in ADVERSARIAL_TEXT {
            let dom = Modal::create(Dom::create_div())
                .with_title(AzString::from(s))
                .dom();
            let title = find_class(&dom, "__azul-native-modal-title");
            if s.is_empty() {
                assert!(title.is_none(), "an empty title must emit no title node");
            } else {
                let title = title.expect("a non-empty title must emit a title node");
                assert_eq!(
                    text_of(title),
                    Some(s),
                    "the title node must carry the string verbatim"
                );
            }
        }
        // documented consequence: a zero-width space is "non-empty", so it emits a
        // title node that renders as nothing but still consumes the title slot
        let zwsp = Modal::create(Dom::create_div())
            .with_title(AzString::from("\u{200B}"))
            .dom();
        assert!(find_class(&zwsp, "__azul-native-modal-title").is_some());
        assert_eq!(panel_kids(&zwsp).len(), 3, "close + (invisible) title + content");
    }
    // ------------------------------------------------------------------
    // Modal::set_content / with_content
    // ------------------------------------------------------------------
    #[test]
    fn set_content_replaces_and_last_write_wins() {
        let a = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("a"));
        let b = Dom::create_text_do_not_use_without_block_level_wrapper("b");
        let mut m = Modal::create(a.clone());
        assert_eq!(m.content, a);
        m.set_content(b.clone());
        assert_eq!(m.content, b, "content must be replaced, not merged");
        assert_eq!(node_count(&m.content), 1);
    }
    #[test]
    fn with_content_matches_set_content_and_keeps_everything_else() {
        let content = nested_content(32);
        let built = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .with_open(true)
            .with_close_button(false)
            .with_content(content.clone());
        let mut mutated = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .with_open(true)
            .with_close_button(false);
        mutated.set_content(content.clone());
        assert_eq!(built, mutated);
        assert_eq!(built.content, content);
        assert_eq!(built.title.as_str(), "t");
        assert!(built.modal_state.inner.open);
        assert!(!built.show_close_button);
    }
    #[test]
    fn content_reaches_the_dom_under_the_content_wrapper() {
        let content = nested_content(64);
        let dom = Modal::create(content.clone()).dom();
        let wrapper = find_class(&dom, "__azul-native-modal-content")
            .expect("the content wrapper must exist");
        let kids = wrapper.children.as_ref();
        assert_eq!(kids.len(), 1, "the wrapper holds exactly the user content");
        assert_eq!(kids[0], content, "the content must be handed through untouched");
    }
    // ------------------------------------------------------------------
    // Modal::set_open / with_open
    // ------------------------------------------------------------------
    #[test]
    fn set_open_flips_the_state_and_the_backdrop_display_together() {
        let mut m = Modal::create(Dom::create_div());
        m.set_open(true);
        assert!(m.modal_state.inner.open);
        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::Flex));
        m.set_open(false);
        assert!(!m.modal_state.inner.open);
        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::None));
    }
    #[test]
    fn set_open_rebuilds_rather_than_appends() {
        // an append-instead-of-rebuild bug would grow the vec on every call and
        // leave two conflicting `display` declarations behind
        let mut m = Modal::create(Dom::create_div());
        let len = m.backdrop_style.as_ref().len();
        for open in [true, true, false, false, true] {
            m.set_open(open);
            assert_eq!(
                m.backdrop_style.as_ref().len(),
                len,
                "set_open must rebuild the style, not extend it"
            );
            let displays: Vec<_> = m
                .backdrop_style
                .as_ref()
                .iter()
                .filter(|p| matches!(p.property, CssProperty::Display(_)))
                .collect();
            assert_eq!(displays.len(), 1, "exactly one `display` may be declared");
        }
        assert!(m.modal_state.inner.open, "the last write must win");
    }
    #[test]
    fn set_open_is_idempotent() {
        let mut once = Modal::create(Dom::create_div());
        once.set_open(true);
        let mut twice = Modal::create(Dom::create_div());
        twice.set_open(true);
        twice.set_open(true);
        assert_eq!(once, twice);
    }
    #[test]
    fn with_open_matches_set_open_and_keeps_the_other_fields() {
        for open in [true, false] {
            let built = Modal::create(Dom::create_div())
                .with_title(AzString::from("t"))
                .with_open(open);
            let mut mutated = Modal::create(Dom::create_div()).with_title(AzString::from("t"));
            mutated.set_open(open);
            assert_eq!(built, mutated);
            assert_eq!(built.title.as_str(), "t");
            assert!(built.show_close_button);
        }
    }
    #[test]
    fn open_state_reaches_the_rendered_backdrop() {
        for open in [true, false] {
            let dom = Modal::create(Dom::create_div()).with_open(open).dom();
            assert_eq!(
                inline_display(&dom),
                Some(if open { LayoutDisplay::Flex } else { LayoutDisplay::None }),
                "open={open}: the backdrop's inline display must match"
            );
            assert!(has_class(&dom, "__azul-native-modal"));
        }
    }
    // ------------------------------------------------------------------
    // Modal::set_close_button / with_close_button
    // ------------------------------------------------------------------
    #[test]
    fn set_close_button_last_write_wins_and_touches_nothing_else() {
        let mut m = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .with_open(true);
        let before = m.backdrop_style.clone();
        for show in [false, true, false] {
            m.set_close_button(show);
            assert_eq!(m.show_close_button, show);
        }
        assert!(!m.show_close_button);
        assert_eq!(m.title.as_str(), "t");
        assert!(m.modal_state.inner.open);
        assert_eq!(m.backdrop_style, before);
    }
    #[test]
    fn with_close_button_matches_set_close_button() {
        for show in [true, false] {
            let built = Modal::create(Dom::create_div()).with_close_button(show);
            let mut mutated = Modal::create(Dom::create_div());
            mutated.set_close_button(show);
            assert_eq!(built, mutated);
        }
    }
    #[test]
    fn hiding_the_close_button_leaves_the_modal_with_no_way_to_close_itself() {
        // TODO2 in the module docs: only the explicit "x" closes the dialog. With
        // the button off there is no callback in the whole tree at all.
        let dom = Modal::create(Dom::create_div())
            .with_close_button(false)
            .with_title(AzString::from("stuck"))
            .dom();
        assert!(find_class(&dom, "__azul-native-modal-close").is_none());
        assert_eq!(count_callbacks(&dom), 0, "no handler is wired anywhere");
        assert_eq!(panel_kids(&dom).len(), 2, "panel is [title, content] only");
    }
    // ------------------------------------------------------------------
    // Modal::set_on_close / with_on_close
    // ------------------------------------------------------------------
    #[test]
    fn set_on_close_stores_the_callback_and_replaces_rather_than_appends() {
        let mut m = Modal::create(Dom::create_div());
        assert!(m.modal_state.on_close.is_none());
        let first = RefAny::new(1u32);
        m.set_on_close(first.clone(), close_cb(record_close));
        assert!(m.modal_state.on_close.is_some());
        assert_eq!(
            m.modal_state.on_close.as_ref().unwrap().callback,
            close_cb(record_close)
        );
        let second = RefAny::new(2u32);
        m.set_on_close(second.clone(), close_cb(close_do_nothing));
        let stored = m.modal_state.on_close.as_ref().expect("still set");
        assert_eq!(stored.callback, close_cb(close_do_nothing), "last write wins");
        assert_eq!(stored.refany, second, "the payload is replaced too");
        assert_ne!(stored.refany, first);
    }
    #[test]
    fn with_on_close_matches_set_on_close_and_keeps_everything_else() {
        let data = RefAny::new(7u64);
        let built = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .with_open(true)
            .with_on_close(data.clone(), close_cb(record_close));
        let mut mutated = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .with_open(true);
        mutated.set_on_close(data.clone(), close_cb(record_close));
        assert_eq!(built, mutated);
        assert_eq!(built.title.as_str(), "t");
        assert!(built.modal_state.inner.open);
        assert!(built.show_close_button);
    }
    #[test]
    fn set_on_close_does_not_switch_the_close_button_on() {
        // Unlike `Alert::set_on_dismiss`, this setter does NOT imply a close
        // button — a caller that turned the "x" off gets a callback that can
        // never fire, and `dom()` silently drops it.
        let data = RefAny::new(CloseLog { calls: Vec::new() });
        let m = Modal::create(Dom::create_div())
            .with_close_button(false)
            .with_on_close(data.clone(), close_cb(record_close));
        assert!(m.modal_state.on_close.is_some(), "the callback is stored");
        assert!(!m.show_close_button, "but the button stays off");
        assert_eq!(count_callbacks(&m.dom()), 0, "so nothing is wired into the DOM");
    }
    // ------------------------------------------------------------------
    // Modal::swap_with_default
    // ------------------------------------------------------------------
    #[test]
    fn swap_with_default_returns_the_original_and_resets_self() {
        let content = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("body"));
        let mut m = Modal::create(content.clone())
            .with_title(AzString::from("Title"))
            .with_open(true)
            .with_close_button(false);
        let old = m.swap_with_default();
        assert_eq!(old.title.as_str(), "Title");
        assert_eq!(old.content, content);
        assert!(old.modal_state.inner.open);
        assert!(!old.show_close_button);
        assert_eq!(m, Modal::default(), "self must be a pristine modal");
        assert_eq!(m.title.as_str(), "");
        assert!(!m.modal_state.inner.open);
        assert!(m.show_close_button);
        assert_eq!(display_of(&m.backdrop_style), Some(LayoutDisplay::None));
    }
    #[test]
    fn swap_with_default_is_stable_when_repeated() {
        let mut m = Modal::create(Dom::create_div()).with_title(AzString::from("t"));
        let _first = m.swap_with_default();
        let second = m.swap_with_default();
        assert_eq!(second, Modal::default());
        assert_eq!(m, Modal::default());
    }
    #[test]
    fn swap_with_default_moves_the_callback_out_of_self() {
        let data = RefAny::new(0u8);
        let mut m = Modal::create(Dom::create_div())
            .with_on_close(data.clone(), close_cb(record_close));
        let old = m.swap_with_default();
        assert!(old.modal_state.on_close.is_some(), "the callback moves out");
        assert!(m.modal_state.on_close.is_none(), "and must not stay behind");
    }
    // ------------------------------------------------------------------
    // Modal::dom
    // ------------------------------------------------------------------
    #[test]
    fn dom_shape_is_backdrop_panel_close_content() {
        let dom = Modal::create(Dom::create_div()).dom();
        assert!(has_class(&dom, "__azul-native-modal"));
        let kids = panel_kids(&dom);
        assert_eq!(kids.len(), 2, "no title -> [close, content]");
        assert!(has_class(&kids[0], "__azul-native-modal-close"));
        assert!(has_class(&kids[1], "__azul-native-modal-content"));
        assert_eq!(node_count(&dom), 6); // close "x" is a <p> + its text leaf
    }
    #[test]
    fn dom_with_a_title_puts_the_close_button_first() {
        // document order is [close, title, content]: the "x" is absolutely
        // positioned, so it may come first without affecting the layout.
        let dom = Modal::create(Dom::create_div())
            .with_title(AzString::from("Title"))
            .dom();
        let kids = panel_kids(&dom);
        assert_eq!(kids.len(), 3);
        assert!(has_class(&kids[0], "__azul-native-modal-close"));
        assert!(has_class(&kids[1], "__azul-native-modal-title"));
        assert!(has_class(&kids[2], "__azul-native-modal-content"));
        assert_eq!(text_of(&kids[1]), Some("Title"));
        assert_eq!(node_count(&dom), 8); // close + title each: <p> + text leaf
    }
    #[test]
    fn dom_close_button_is_a_focusable_multiplication_sign_with_one_mouseup_handler() {
        let dom = Modal::create(Dom::create_div()).dom();
        let close = find_class(&dom, "__azul-native-modal-close").expect("close node");
        assert_eq!(
            text_of(close),
            Some("\u{00D7}"),
            "the glyph must be U+00D7 MULTIPLICATION SIGN, not ASCII 'x'"
        );
        assert_eq!(close.root.get_tab_index(), Some(TabIndex::Auto));
        let cbs = close.root.get_callbacks();
        assert_eq!(cbs.as_ref().len(), 1, "exactly one handler on the 'x'");
        let entry = &cbs.as_ref()[0];
        assert_eq!(
            entry.event,
            EventFilter::Hover(HoverEventFilter::MouseUp),
            "the modal closes on mouse-up, not mouse-down"
        );
        assert_eq!(entry.callback.cb, on_modal_close as usize);
        assert_eq!(count_callbacks(&dom), 1, "and nowhere else in the tree");
    }
    #[test]
    fn dom_hands_a_snapshot_of_the_modal_state_to_the_close_button() {
        for open in [true, false] {
            let dom = Modal::create(Dom::create_div()).with_open(open).dom();
            let close = find_class(&dom, "__azul-native-modal-close").expect("close node");
            let mut payload = close.root.get_callbacks().as_ref()[0].refany.clone();
            assert_eq!(
                wrapper_open(&mut payload),
                open,
                "the payload must carry the open state at build time"
            );
        }
    }
    #[test]
    fn dom_payloads_of_two_clones_are_independent() {
        // the payload is a *snapshot*: flipping one rendered modal's state must
        // not reach through to another render of the same builder
        let m = Modal::create(Dom::create_div()).with_open(true);
        let a = m.clone().dom();
        let b = m.dom();
        let mut pa = find_class(&a, "__azul-native-modal-close")
            .expect("close")
            .root
            .get_callbacks()
            .as_ref()[0]
            .refany
            .clone();
        let mut pb = find_class(&b, "__azul-native-modal-close")
            .expect("close")
            .root
            .get_callbacks()
            .as_ref()[0]
            .refany
            .clone();
        assert_ne!(pa, pb, "two renders must not share one RefAny allocation");
        let (_update, _changes) = run_close(Some(StyledDom::create_from_dom(a)), 2, pa.clone());
        assert!(!wrapper_open(&mut pa));
        assert!(wrapper_open(&mut pb), "the other render must be untouched");
    }
    #[test]
    fn dom_applies_the_static_styles_verbatim() {
        let dom = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .dom();
        let kids = panel_kids(&dom);
        let want = |s: &[CssPropertyWithConditions]| {
            s.iter().map(|p| p.property.clone()).collect::<Vec<_>>()
        };
        assert_eq!(inline_props(panel(&dom)), want(MODAL_PANEL_STYLE));
        assert_eq!(inline_props(&kids[0]), want(MODAL_CLOSE_STYLE));
        assert_eq!(inline_props(&kids[1]), want(MODAL_TITLE_STYLE));
        assert_eq!(inline_props(&kids[2]), want(MODAL_CONTENT_STYLE));
    }
    #[test]
    fn dom_backdrop_style_is_exactly_the_builders_backdrop_style() {
        for open in [true, false] {
            let m = Modal::create(Dom::create_div()).with_open(open);
            let expected = style_props(&m.backdrop_style);
            assert_eq!(inline_props(&m.dom()), expected);
        }
    }
    #[test]
    fn panel_geometry_matches_the_documented_constants() {
        let props = style_props(&CssPropertyWithConditionsVec::from_const_slice(
            MODAL_PANEL_STYLE,
        ));
        for want in [
            CssProperty::const_min_width(LayoutMinWidth::const_px(PANEL_MIN_WIDTH)),
            CssProperty::const_max_width(LayoutMaxWidth::const_px(PANEL_MAX_WIDTH)),
            CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(
                PANEL_RADIUS,
            )),
            CssProperty::const_position(LayoutPosition::Relative),
            CssProperty::const_display(LayoutDisplay::Flex),
        ] {
            assert!(props.contains(&want), "panel style is missing {want:?}");
        }
        let (min, max) = (PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
        assert!(min <= max, "min-width {min} must not exceed max-width {max}");
    }
    #[test]
    fn every_static_style_declares_each_property_at_most_once() {
        for (name, style) in [
            ("panel", MODAL_PANEL_STYLE),
            ("title", MODAL_TITLE_STYLE),
            ("close", MODAL_CLOSE_STYLE),
            ("content", MODAL_CONTENT_STYLE),
        ] {
            let types = property_types(&CssPropertyWithConditionsVec::from_const_slice(style));
            for (i, a) in types.iter().enumerate() {
                for b in &types[i + 1..] {
                    assert_ne!(a, b, "{name}: the same property is declared twice");
                }
            }
            for p in style {
                assert!(
                    p.apply_if.as_ref().is_empty(),
                    "{name}: {:?} must be unconditional",
                    p.property
                );
            }
        }
    }
    #[test]
    fn dom_survives_extreme_content_and_titles() {
        // deep nesting, a huge sibling list and an adversarial title at once
        let content = Dom::create_div().with_children(DomVec::from_vec(
            (0..2_000).map(|_| nested_content(4)).collect::<Vec<_>>(),
        ));
        let dom = Modal::create(content)
            .with_title(AzString::from("\u{202E}\u{1F469}\u{200D}\u{1F467}\0"))
            .with_open(true)
            .dom();
        // backdrop + panel + close(<p>+text) + title(<p>+text) + wrapper +
        // content root + 2000*5
        assert_eq!(node_count(&dom), 8 + 2_000 * 5);
        assert_eq!(count_callbacks(&dom), 1);
    }
    #[test]
    fn from_modal_for_dom_equals_dom() {
        // no RefAny is involved once the close button is off, so the two renders
        // are value-comparable
        let m = Modal::create(Dom::create_div())
            .with_title(AzString::from("t"))
            .with_close_button(false)
            .with_open(true);
        assert_eq!(Dom::from(m.clone()), m.dom());
    }
    #[test]
    fn two_renders_with_a_close_button_differ_only_in_the_refany_identity() {
        // RefAny equality is allocation identity, so two renders of the *same*
        // modal are NOT equal — but they are once the button (and its payload)
        // is gone.
        let m = Modal::create(Dom::create_div());
        assert_ne!(m.clone().dom(), m.clone().dom());
        let q = m.with_close_button(false);
        assert_eq!(q.clone().dom(), q.dom());
    }
    // ------------------------------------------------------------------
    // on_modal_close
    // ------------------------------------------------------------------
    #[test]
    fn close_hides_the_backdrop_and_flips_open() {
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: OptionModalOnClose::None,
        });
        // node 2 == the close button; its parent is the panel(1), whose parent is
        // the backdrop(0)
        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data.clone());
        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(0usize, LayoutDisplay::None)],
            "the *backdrop* (not the panel or the button) must be hidden"
        );
        assert!(!wrapper_open(&mut data), "state must flip to closed");
    }
    #[test]
    fn close_invokes_the_user_callback_with_the_already_flipped_state() {
        let mut log = RefAny::new(CloseLog { calls: Vec::new() });
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: Some(ModalOnClose {
                callback: close_cb(record_close),
                refany: log.clone(),
            })
            .into(),
        });
        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data.clone());
        assert_eq!(update, Update::RefreshDom, "the user callback's Update is returned");
        assert_eq!(
            log_calls(&mut log),
            alloc::vec![false],
            "the callback must see `open == false` (already closed)"
        );
        assert!(!wrapper_open(&mut data));
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(0usize, LayoutDisplay::None)],
            "the backdrop is hidden even after a user callback ran"
        );
    }
    #[test]
    fn close_hides_the_backdrop_even_when_the_user_callback_does_nothing() {
        let data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: Some(ModalOnClose {
                callback: close_cb(close_do_nothing),
                refany: RefAny::new(0u8),
            })
            .into(),
        });
        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
    }
    #[test]
    fn close_twice_is_idempotent() {
        let mut log = RefAny::new(CloseLog { calls: Vec::new() });
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: Some(ModalOnClose {
                callback: close_cb(record_close),
                refany: log.clone(),
            })
            .into(),
        });
        for _ in 0..2 {
            let (update, changes) = run_close(Some(modal_styled_dom()), 2, data.clone());
            assert_eq!(update, Update::RefreshDom);
            assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
        }
        assert!(!wrapper_open(&mut data), "a second close must not re-open");
        assert_eq!(
            log_calls(&mut log),
            alloc::vec![false, false],
            "each click fires the callback exactly once, always with open == false"
        );
    }
    #[test]
    fn close_on_the_panel_is_a_noop_because_the_backdrop_has_no_parent() {
        // hit node 1 == the panel: parent is the backdrop(0), which has no parent
        // of its own, so the handler bails *before* touching the state
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: OptionModalOnClose::None,
        });
        let (update, changes) = run_close(Some(modal_styled_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "nothing may be restyled without a grandparent");
        assert!(wrapper_open(&mut data), "state must not flip");
    }
    #[test]
    fn close_on_the_root_is_a_noop() {
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: OptionModalOnClose::None,
        });
        let (update, changes) = run_close(Some(modal_styled_dom()), 0, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(wrapper_open(&mut data));
    }
    #[test]
    fn close_with_a_stale_hit_node_is_a_noop() {
        // node 999 does not exist in the 6-node fixture
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: OptionModalOnClose::None,
        });
        let (update, changes) = run_close(Some(modal_styled_dom()), 999, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(wrapper_open(&mut data));
    }
    #[test]
    fn close_without_any_layout_result_is_a_noop() {
        let mut data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: OptionModalOnClose::None,
        });
        let (update, changes) = run_close(None, 2, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(wrapper_open(&mut data), "state must not flip");
    }
    #[test]
    fn close_with_a_foreign_payload_is_a_noop() {
        // the callback-bearing node carries a RefAny of the *wrong* type: the
        // downcast fails before the restyle, so nothing is hidden
        let data = RefAny::new(0xdead_beef_u64);
        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a foreign payload must not hide the backdrop");
    }
    #[test]
    fn close_with_a_plain_modal_state_payload_is_a_noop() {
        // `ModalState` and `ModalStateWrapper` are different types — handing the
        // inner state alone must not be silently accepted
        let data = RefAny::new(ModalState { open: true });
        let (update, changes) = run_close(Some(modal_styled_dom()), 2, data);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn close_hides_the_grandparent_whatever_it_is() {
        // The handler hides `parent(parent(hit))` unconditionally. On a deeper
        // tree that is NOT the root — documenting why the close button has to
        // stay a direct child of the panel.
        let deep = StyledDom::create_from_dom(nested_content(3));
        assert_eq!(deep.node_hierarchy.as_ref().len(), 4);
        let data = RefAny::new(ModalStateWrapper {
            inner: ModalState { open: true },
            on_close: OptionModalOnClose::None,
        });
        let (update, changes) = run_close(Some(deep), 3, data);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(1usize, LayoutDisplay::None)],
            "node 1 (the grandparent), not the root, gets hidden"
        );
    }
    #[test]
    fn close_end_to_end_through_the_real_dom_payload() {
        // Take the *actual* RefAny the widget wired into its close button and
        // drive the *actual* handler the widget registered against it.
        let modal = Modal::create(Dom::create_text_do_not_use_without_block_level_wrapper("body"))
            .with_title(AzString::from("Title"))
            .with_open(true);
        let dom = modal.dom();
        let close = find_class(&dom, "__azul-native-modal-close").expect("close node");
        let entry = &close.root.get_callbacks().as_ref()[0];
        assert_eq!(entry.callback.cb, on_modal_close as usize);
        let mut payload = entry.refany.clone();
        assert!(wrapper_open(&mut payload), "the snapshot starts open");
        let styled = StyledDom::create_from_dom(dom);
        // backdrop(0), panel(1), close <p>(2) + text(3), title <p>(4) + text(5),
        // wrapper(6), content(7)
        assert_eq!(styled.node_hierarchy.as_ref().len(), 8);
        let (update, changes) = run_close(Some(styled), 2, payload.clone());
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(0usize, LayoutDisplay::None)]
        );
        assert!(
            !wrapper_open(&mut payload),
            "the state living in the DOM must be flipped to closed"
        );
    }
    #[test]
    fn close_end_to_end_invokes_a_user_callback_wired_through_the_builder() {
        let mut log = RefAny::new(CloseLog { calls: Vec::new() });
        let dom = Modal::create(Dom::create_div())
            .with_open(true)
            .with_on_close(log.clone(), close_cb(record_close))
            .dom();
        let payload = find_class(&dom, "__azul-native-modal-close")
            .expect("close node")
            .root
            .get_callbacks()
            .as_ref()[0]
            .refany
            .clone();
        let (update, changes) = run_close(Some(StyledDom::create_from_dom(dom)), 2, payload);
        assert_eq!(update, Update::RefreshDom);
        assert_eq!(log_calls(&mut log), alloc::vec![false]);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(0usize, LayoutDisplay::None)]
        );
    }
    // ------------------------------------------------------------------
    // ModalState / ModalStateWrapper invariants
    // ------------------------------------------------------------------
    #[test]
    fn modal_state_defaults_to_closed_with_no_callback() {
        assert!(!ModalState::default().open);
        let w = ModalStateWrapper::default();
        assert!(!w.inner.open);
        assert!(w.on_close.is_none());
        assert_eq!(w, ModalStateWrapper::default());
    }
    #[test]
    fn modal_state_is_copy_and_value_comparable() {
        let a = ModalState { open: true };
        let b = a; // Copy
        assert_eq!(a, b);
        assert_ne!(a, ModalState { open: false });
    }
}