1
//! Popover widget — wraps an arbitrary anchor [`Dom`] and shows an
2
//! absolutely-positioned floating panel holding arbitrary `content: Dom` when
3
//! the anchor is **clicked** (toggling open/closed). A click-triggered sibling
4
//! of [`crate::widgets::tooltip::Tooltip`] (which is hover-triggered and
5
//! text-only): the CSS show/hide popup mechanism is identical, but the panel
6
//! holds a whole [`Dom`] and is toggled by an internal click handler that flips
7
//! a [`PopoverState`].
8
//!
9
//! Structure: a `position: relative` wrapper containing a clickable *trigger*
10
//! (which holds the anchor) followed by the absolutely-positioned *content*
11
//! panel, hidden by default (`display: none`). Clicking the trigger flips
12
//! `open`, invokes the optional user `on_toggle(state)`, and shows/hides the
13
//! panel via `set_css_property(display)` (mirroring the live-restyle pattern of
14
//! check_box / accordion).
15
//!
16
//! TODO2: like [`Tooltip`], this is a CSS simplification of a "real" floating
17
//! popover. The panel is placed at a fixed offset below the trigger (it does not
18
//! measure the trigger's height, flip when near a screen edge, escape an
19
//! `overflow: hidden` ancestor, or raise its z-order — it relies on being the
20
//! later sibling to paint on top). There is also no "click-outside to dismiss"
21
//! and no `Escape` handling — clicking the trigger again is the only way to
22
//! close it (clicking *inside* the panel does not close it, since the handler is
23
//! on the trigger, not the wrapper). A future revision could route through the
24
//! window-popup / menu popup path for true screen-anchored positioning and
25
//! outside-click dismissal once that is runtime-verifiable.
26
//!
27
//! Key types: [`Popover`], [`PopoverState`], [`PopoverOnToggle`].
28

            
29
use azul_core::{
30
    callbacks::{CoreCallbackData, Update},
31
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
32
    refany::RefAny,
33
};
34
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
35
use azul_css::{
36
    props::{
37
        basic::{color::ColorU, *},
38
        layout::{LayoutDisplay, LayoutPosition, LayoutFlexGrow, LayoutTop, LayoutLeft, LayoutMinWidth, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
39
        property::{CssProperty, *},
40
        style::{StyleCursor, StyleBackgroundContentVec, StyleBackgroundContent, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius},
41
    },
42
    impl_option_inner, AzString,
43
};
44

            
45
use crate::callbacks::{Callback, CallbackInfo};
46

            
47
static POPOVER_WRAPPER_CLASS: &[IdOrClass] =
48
    &[Class(AzString::from_const_str("__azul-native-popover"))];
49
static POPOVER_TRIGGER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
50
    "__azul-native-popover-trigger",
51
))];
52
static POPOVER_CONTENT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
53
    "__azul-native-popover-content",
54
))];
55

            
56
// ---- layout (logical px) ----
57
/// Fixed vertical offset of the panel below the wrapper's top edge. A
58
/// simplification — see the module-level `TODO2`.
59
const CONTENT_OFFSET_Y: isize = 32;
60
/// Minimum width of the floating panel.
61
const CONTENT_MIN_WIDTH: isize = 160;
62
const CONTENT_RADIUS: isize = 6;
63

            
64
// ---- colours ----
65
/// Panel background (white).
66
const CONTENT_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
67
/// Panel border (#cccccc).
68
const CONTENT_BORDER_COLOR: ColorU = ColorU { r: 204, g: 204, b: 204, a: 255 };
69

            
70
/// Callback function type invoked when a popover is toggled. The [`PopoverState`]
71
/// carries the *new* open/closed value.
72
pub type PopoverOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, PopoverState) -> Update;
73
impl_widget_callback!(
74
    PopoverOnToggle,
75
    OptionPopoverOnToggle,
76
    PopoverOnToggleCallback,
77
    PopoverOnToggleCallbackType
78
);
79

            
80
azul_core::impl_managed_callback! {
81
    wrapper:        PopoverOnToggleCallback,
82
    info_ty:        CallbackInfo,
83
    return_ty:      Update,
84
    default_ret:    Update::DoNothing,
85
    invoker_static: POPOVER_ON_TOGGLE_INVOKER,
86
    invoker_ty:     AzPopoverOnToggleCallbackInvoker,
87
    thunk_fn:       az_popover_on_toggle_callback_thunk,
88
    setter_fn:      AzApp_setPopoverOnToggleCallbackInvoker,
89
    from_handle_fn: AzPopoverOnToggleCallback_createFromHostHandle,
90
    extra_args:     [ state: PopoverState ],
91
}
92

            
93
/// A click-triggered floating panel anchored to an arbitrary [`Dom`].
94
#[derive(Debug, Clone, PartialEq, Eq)]
95
#[repr(C)]
96
pub struct Popover {
97
    /// Runtime state (`open`) plus the optional toggle callback.
98
    pub popover_state: PopoverStateWrapper,
99
    /// The element that, when clicked, toggles the panel.
100
    pub anchor: Dom,
101
    /// The content shown inside the floating panel.
102
    pub content: Dom,
103
    /// Style of the positioning wrapper around the trigger + panel.
104
    pub wrapper_style: CssPropertyWithConditionsVec,
105
    /// Style of the floating content panel (includes its current `display`).
106
    pub content_style: CssPropertyWithConditionsVec,
107
}
108

            
109
#[derive(Debug, Default, Clone, PartialEq, Eq)]
110
#[repr(C)]
111
pub struct PopoverStateWrapper {
112
    /// Whether the panel is currently open.
113
    pub inner: PopoverState,
114
    /// Optional: function to call when the popover is toggled.
115
    pub on_toggle: OptionPopoverOnToggle,
116
}
117

            
118
/// The open/closed state of a [`Popover`].
119
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
120
#[repr(C)]
121
pub struct PopoverState {
122
    /// `true` = panel shown, `false` (default) = panel hidden.
123
    pub open: bool,
124
}
125

            
126
/// Wrapper around the trigger + panel: an inline-block positioning context so
127
/// the absolutely-positioned panel is placed relative to it.
128
static POPOVER_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
129
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
130
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
131
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
132
];
133

            
134
/// The clickable trigger holding the anchor.
135
static POPOVER_TRIGGER_STYLE: &[CssPropertyWithConditions] = &[
136
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
137
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
138
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
139
];
140

            
141
/// Builds the floating-panel style. Only the `display` (open vs closed) differs
142
/// between states; all the positioning/visual props are present in both so the
143
/// runtime `set_css_property(display)` toggle has everything it needs (mirroring
144
/// the accordion body-style approach).
145
96
fn build_content_style(open: bool) -> CssPropertyWithConditionsVec {
146
96
    let display = if open {
147
31
        LayoutDisplay::Block
148
    } else {
149
65
        LayoutDisplay::None
150
    };
151
96
    let bg_vec = StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(
152
96
        CONTENT_BG_COLOR
153
96
    )]);
154
96
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
155
96
        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
156
96
        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
157
96
        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(
158
            CONTENT_OFFSET_Y,
159
        ))),
160
96
        CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
161
96
        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
162
            CONTENT_MIN_WIDTH,
163
        ))),
164
        // padding: 8px
165
96
        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
166
            8,
167
        ))),
168
96
        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
169
96
            LayoutPaddingBottom::const_px(8),
170
        )),
171
96
        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
172
96
            LayoutPaddingLeft::const_px(8),
173
        )),
174
96
        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
175
96
            LayoutPaddingRight::const_px(8),
176
        )),
177
        // border: 1px solid #cccccc
178
96
        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
179
96
            LayoutBorderTopWidth::const_px(1),
180
        )),
181
96
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
182
96
            LayoutBorderBottomWidth::const_px(1),
183
        )),
184
96
        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
185
96
            LayoutBorderLeftWidth::const_px(1),
186
        )),
187
96
        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
188
96
            LayoutBorderRightWidth::const_px(1),
189
        )),
190
96
        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
191
96
            inner: BorderStyle::Solid,
192
96
        })),
193
96
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
194
96
            StyleBorderBottomStyle {
195
96
                inner: BorderStyle::Solid,
196
96
            },
197
        )),
198
96
        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
199
96
            inner: BorderStyle::Solid,
200
96
        })),
201
96
        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
202
96
            StyleBorderRightStyle {
203
96
                inner: BorderStyle::Solid,
204
96
            },
205
        )),
206
96
        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
207
96
            inner: CONTENT_BORDER_COLOR,
208
96
        })),
209
96
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
210
96
            StyleBorderBottomColor {
211
96
                inner: CONTENT_BORDER_COLOR,
212
96
            },
213
        )),
214
96
        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
215
96
            inner: CONTENT_BORDER_COLOR,
216
96
        })),
217
96
        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
218
96
            StyleBorderRightColor {
219
96
                inner: CONTENT_BORDER_COLOR,
220
96
            },
221
        )),
222
        // border-radius: 6px
223
96
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
224
96
            StyleBorderTopLeftRadius::const_px(CONTENT_RADIUS),
225
        )),
226
96
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
227
96
            StyleBorderTopRightRadius::const_px(CONTENT_RADIUS),
228
        )),
229
96
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
230
96
            StyleBorderBottomLeftRadius::const_px(CONTENT_RADIUS),
231
        )),
232
96
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
233
96
            StyleBorderBottomRightRadius::const_px(CONTENT_RADIUS),
234
        )),
235
96
        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
236
    ])
237
96
}
238

            
239
impl Popover {
240
    /// Creates a popover whose `anchor`, when clicked, toggles a panel holding
241
    /// `content`. The panel starts closed.
242
42
    #[must_use] pub fn new(anchor: Dom, content: Dom) -> Self {
243
42
        Self {
244
42
            popover_state: PopoverStateWrapper::default(),
245
42
            anchor,
246
42
            content,
247
42
            wrapper_style: CssPropertyWithConditionsVec::from_const_slice(POPOVER_WRAPPER_STYLE),
248
42
            content_style: build_content_style(false),
249
42
        }
250
42
    }
251

            
252
    /// Sets whether the panel starts open, recomputing the panel style.
253
    #[inline]
254
29
    pub fn set_open(&mut self, open: bool) {
255
29
        self.popover_state.inner.open = open;
256
29
        self.content_style = build_content_style(open);
257
29
    }
258

            
259
    /// Builder-style setter for the initial open state.
260
    #[inline]
261
20
    #[must_use] pub fn with_open(mut self, open: bool) -> Self {
262
20
        self.set_open(open);
263
20
        self
264
20
    }
265

            
266
    /// Sets the toggle callback (invoked with the new state on every toggle).
267
    #[inline]
268
8
    pub fn set_on_toggle<C: Into<PopoverOnToggleCallback>>(&mut self, data: RefAny, on_toggle: C) {
269
8
        self.popover_state.on_toggle = Some(PopoverOnToggle {
270
8
            callback: on_toggle.into(),
271
8
            refany: data,
272
8
        })
273
8
        .into();
274
8
    }
275

            
276
    /// Builder-style setter for the toggle callback.
277
    #[inline]
278
4
    #[must_use] pub fn with_on_toggle<C: Into<PopoverOnToggleCallback>>(
279
4
        mut self,
280
4
        data: RefAny,
281
4
        on_toggle: C,
282
4
    ) -> Self {
283
4
        self.set_on_toggle(data, on_toggle);
284
4
        self
285
4
    }
286

            
287
    /// Replaces `self` with a default (empty) popover and returns the original.
288
    #[inline]
289
3
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
290
3
        let mut s = Self::new(Dom::default(), Dom::default());
291
3
        core::mem::swap(&mut s, self);
292
3
        s
293
3
    }
294

            
295
    /// Renders the popover into a [`Dom`] subtree with the `__azul-native-popover`
296
    /// class.
297
17
    #[must_use] pub fn dom(self) -> Dom {
298
        use azul_core::{callbacks::CoreCallback, dom::{EventFilter, HoverEventFilter}, refany::OptionRefAny};
299

            
300
        // The trigger carries the click handler + the shared state. Clicking the
301
        // anchor (a descendant of the trigger) bubbles up to it (currentTarget
302
        // semantics — see `radio_group`), so `get_hit_node()` resolves to the
303
        // trigger regardless of what inside the anchor was clicked. Clicking the
304
        // panel does NOT toggle, since the panel is a sibling, not a child.
305
17
        let trigger = Dom::create_div()
306
17
            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_TRIGGER_CLASS))
307
17
            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(POPOVER_TRIGGER_STYLE))
308
17
            .with_tab_index(TabIndex::Auto)
309
17
            .with_callbacks(
310
17
                vec![CoreCallbackData {
311
17
                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
312
17
                    callback: CoreCallback {
313
17
                        cb: on_popover_toggle as usize,
314
17
                        ctx: OptionRefAny::None,
315
17
                    },
316
17
                    refany: RefAny::new(self.popover_state),
317
17
                }]
318
17
                .into(),
319
            )
320
17
            .with_children(vec![self.anchor].into());
321

            
322
17
        let content = Dom::create_div()
323
17
            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_CONTENT_CLASS))
324
17
            .with_css_props(self.content_style)
325
17
            .with_children(vec![self.content].into());
326

            
327
17
        Dom::create_div()
328
17
            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_WRAPPER_CLASS))
329
17
            .with_css_props(self.wrapper_style)
330
            // children: [trigger, content] — the panel is the trigger's next sibling.
331
17
            .with_children(vec![trigger, content].into())
332
17
    }
333
}
334

            
335
impl Default for Popover {
336
14
    fn default() -> Self {
337
14
        Self::new(Dom::default(), Dom::default())
338
14
    }
339
}
340

            
341
/// Trigger click handler. The hit node is the trigger (the callback-bearing
342
/// node, per `currentTarget` semantics — see `radio_group`); its next sibling is
343
/// the content panel. Flips `open`, invokes the optional user callback with the
344
/// new state, then shows/hides the panel via `display`.
345
20
extern "C" fn on_popover_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
346
20
    let trigger = info.get_hit_node();
347
20
    let Some(content) = info.get_next_sibling(trigger) else {
348
4
        return Update::DoNothing;
349
    };
350

            
351
15
    let (now_open, result) = {
352
16
        let Some(mut pop) = data.downcast_mut::<PopoverStateWrapper>() else {
353
1
            return Update::DoNothing;
354
        };
355
15
        pop.inner.open = !pop.inner.open;
356
15
        let now_open = pop.inner.open;
357
15
        let inner = pop.inner;
358
15
        let pop = &mut *pop;
359
15
        let result = match pop.on_toggle.as_mut() {
360
3
            Some(PopoverOnToggle { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
361
12
            None => Update::DoNothing,
362
        };
363
15
        (now_open, result)
364
    };
365

            
366
    // TODO2: shows/hides the panel by toggling `display` via set_css_property.
367
    // This follows the proven live-restyle pattern of accordion/check_box; the
368
    // display:none/block relayout itself is not GUI-verified in this build.
369
15
    let display = if now_open {
370
9
        LayoutDisplay::Block
371
    } else {
372
6
        LayoutDisplay::None
373
    };
374
15
    info.set_css_property(content, CssProperty::const_display(display));
375

            
376
15
    result
377
20
}
378

            
379
impl From<Popover> for Dom {
380
1
    fn from(p: Popover) -> Self {
381
1
        p.dom()
382
1
    }
383
}
384

            
385
#[cfg(test)]
386
mod autotest_generated {
387
    use std::{
388
        collections::{BTreeMap, HashMap},
389
        sync::{Arc, Mutex},
390
    };
391

            
392
    use azul_core::{
393
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
394
        geom::{LogicalRect, OptionLogicalPosition},
395
        gl::OptionGlContextPtr,
396
        hit_test::ScrollPosition,
397
        refany::OptionRefAny,
398
        resources::RendererResources,
399
        styled_dom::{NodeHierarchyItemId, StyledDom},
400
        window::{MonitorVec, RawWindowHandle},
401
    };
402
    use azul_css::{props::property::CssPropertyType, system::SystemStyle};
403
    use rust_fontconfig::FcFontCache;
404

            
405
    use super::*;
406
    #[cfg(feature = "icu")]
407
    use crate::icu::IcuLocalizerHandle;
408
    use crate::{
409
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
410
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
411
        window::{DomLayoutResult, LayoutWindow},
412
        window_state::FullWindowState,
413
    };
414

            
415
    // ------------------------------------------------------------------
416
    // Helpers
417
    // ------------------------------------------------------------------
418

            
419
    /// True if `node` carries the CSS class `name`.
420
    fn has_class(node: &Dom, name: &str) -> bool {
421
        node.root
422
            .get_ids_and_classes()
423
            .as_ref()
424
            .iter()
425
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
426
    }
427

            
428
    /// The text of a `NodeType::Text` node (`None` for any other node type).
429
    fn text_of(node: &Dom) -> Option<&str> {
430
        match node.root.get_node_type() {
431
            NodeType::Text(s) => Some(s.as_ref().as_str()),
432
            _ => None,
433
        }
434
    }
435

            
436
    /// The `display` value in a node's *inline* style, if it sets one.
437
    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
438
        node.root
439
            .style
440
            .iter_inline_properties()
441
            .find_map(|(p, _)| match p {
442
                CssProperty::Display(v) => v.get_property().copied(),
443
                _ => None,
444
            })
445
    }
446

            
447
    /// The property *types* of a style vec, in declaration order.
448
    fn prop_types(style: &CssPropertyWithConditionsVec) -> Vec<CssPropertyType> {
449
        style
450
            .as_ref()
451
            .iter()
452
            .map(|p| p.property.get_type())
453
            .collect()
454
    }
455

            
456
    /// *Every* `display` value declared in a style vec (order preserved) — a
457
    /// second entry would silently shadow the first.
458
    fn displays_in(style: &CssPropertyWithConditionsVec) -> Vec<LayoutDisplay> {
459
        style
460
            .as_ref()
461
            .iter()
462
            .filter_map(|p| match &p.property {
463
                CssProperty::Display(v) => v.get_property().copied(),
464
                _ => None,
465
            })
466
            .collect()
467
    }
468

            
469
    /// The `CssPropertyType` of `display`, without hard-coding the enum variant.
470
    fn display_ty() -> CssPropertyType {
471
        CssProperty::const_display(LayoutDisplay::None).get_type()
472
    }
473

            
474
    /// A three-node styled DOM — `root(0)` with children `trigger(1)` and
475
    /// `panel(2)` — i.e. the exact hierarchy `on_popover_toggle` walks
476
    /// (`hit node` -> `next sibling`).
477
    fn trigger_panel_dom() -> StyledDom {
478
        let styled = StyledDom::create_from_dom(
479
            Dom::create_div()
480
                .with_child(Dom::create_div())
481
                .with_child(Dom::create_div()),
482
        );
483
        assert_eq!(
484
            styled.node_hierarchy.as_ref().len(),
485
            3,
486
            "fixture must flatten to exactly wrapper/trigger/panel"
487
        );
488
        styled
489
    }
490

            
491
    /// Index of the first node carrying `class` in a flattened `StyledDom`.
492
    fn index_of_class(styled: &StyledDom, class: &str) -> usize {
493
        styled
494
            .node_data
495
            .as_ref()
496
            .iter()
497
            .position(|nd| {
498
                nd.get_ids_and_classes()
499
                    .as_ref()
500
                    .iter()
501
                    .any(|c| matches!(c, Class(s) if s.as_str() == class))
502
            })
503
            .unwrap_or_else(|| panic!("no node with class {class} in the flattened DOM"))
504
    }
505

            
506
    /// A `DomLayoutResult` with an *empty* layout tree: the toggle handler only
507
    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
508
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
509
        DomLayoutResult {
510
            styled_dom,
511
            layout_tree: LayoutTree {
512
                nodes: Vec::new(),
513
                warm: Vec::new(),
514
                cold: Vec::new(),
515
                root: 0,
516
                dom_to_layout: BTreeMap::new(),
517
                children_arena: Vec::new(),
518
                children_offsets: Vec::new(),
519
                subtree_needs_intrinsic: Vec::new(),
520
            },
521
            calculated_positions: Vec::new(),
522
            viewport: LogicalRect::zero(),
523
            display_list: Arc::new(DisplayList::default()),
524
            scroll_ids: HashMap::new(),
525
            scroll_id_to_node_id: HashMap::new(),
526
        }
527
    }
528

            
529
    /// Invokes `on_popover_toggle` against a `LayoutWindow` holding `styled` (or
530
    /// nothing at all, when `styled` is `None`), with `hit` as the hit node.
531
    /// Returns the `Update` plus every recorded `CallbackChange`.
532
    fn run_toggle(
533
        styled: Option<StyledDom>,
534
        hit: usize,
535
        data: RefAny,
536
    ) -> (Update, Vec<CallbackChange>) {
537
        let mut layout_window =
538
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
539
        if let Some(sd) = styled {
540
            layout_window
541
                .layout_results
542
                .insert(DomId::ROOT_ID, layout_result(sd));
543
        }
544

            
545
        let renderer_resources = RendererResources::default();
546
        let previous_window_state: Option<FullWindowState> = None;
547
        let current_window_state = FullWindowState::default();
548
        let gl_context = OptionGlContextPtr::None;
549
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
550
            BTreeMap::new();
551
        let window_handle = RawWindowHandle::Unsupported;
552
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
553

            
554
        let ref_data = CallbackInfoRefData {
555
            layout_window: &layout_window,
556
            renderer_resources: &renderer_resources,
557
            previous_window_state: &previous_window_state,
558
            current_window_state: &current_window_state,
559
            gl_context: &gl_context,
560
            current_scroll_manager: &scroll_states,
561
            current_window_handle: &window_handle,
562
            system_callbacks: &system_callbacks,
563
            system_style: Arc::new(SystemStyle::default()),
564
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
565
            #[cfg(feature = "icu")]
566
            icu_localizer: IcuLocalizerHandle::default(),
567
            ctx: OptionRefAny::None,
568
        };
569

            
570
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
571

            
572
        let info = CallbackInfo::new(
573
            &ref_data,
574
            &changes,
575
            DomNodeId {
576
                dom: DomId::ROOT_ID,
577
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
578
            },
579
            OptionLogicalPosition::None,
580
            OptionLogicalPosition::None,
581
        );
582

            
583
        let update = on_popover_toggle(data, info);
584
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
585
        (update, recorded)
586
    }
587

            
588
    /// Every `display` write recorded in the change log, as `(node index, display)`.
589
    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
590
        let mut out = Vec::new();
591
        for change in changes {
592
            if let CallbackChange::ChangeNodeCssProperties {
593
                node_id, properties, ..
594
            } = change
595
            {
596
                for p in properties.as_ref() {
597
                    if let CssProperty::Display(v) = p {
598
                        if let Some(d) = v.get_property() {
599
                            out.push((node_id.index(), *d));
600
                        }
601
                    }
602
                }
603
            }
604
        }
605
        out
606
    }
607

            
608
    /// `open` of a `PopoverStateWrapper` payload.
609
    fn payload_open(data: &mut RefAny) -> bool {
610
        data.downcast_ref::<PopoverStateWrapper>()
611
            .expect("payload must still be a PopoverStateWrapper")
612
            .inner
613
            .open
614
    }
615

            
616
    /// Records the states it is invoked with; used as a user `on_toggle`.
617
    struct ToggleLog {
618
        calls: Vec<bool>,
619
    }
620

            
621
    extern "C" fn record_toggle(mut data: RefAny, _: CallbackInfo, state: PopoverState) -> Update {
622
        if let Some(mut log) = data.downcast_mut::<ToggleLog>() {
623
            log.calls.push(state.open);
624
        }
625
        Update::RefreshDom
626
    }
627

            
628
    extern "C" fn toggle_do_nothing(_: RefAny, _: CallbackInfo, _: PopoverState) -> Update {
629
        Update::DoNothing
630
    }
631

            
632
    fn toggle_cb(f: PopoverOnToggleCallbackType) -> PopoverOnToggleCallback {
633
        f.into()
634
    }
635

            
636
    // ------------------------------------------------------------------
637
    // build_content_style
638
    // ------------------------------------------------------------------
639

            
640
    #[test]
641
    fn content_style_open_and_closed_differ_only_in_display() {
642
        let closed = build_content_style(false);
643
        let open = build_content_style(true);
644

            
645
        assert_eq!(
646
            closed.len(),
647
            open.len(),
648
            "both states must declare the same props so the runtime display toggle \
649
             has everything it needs"
650
        );
651
        assert_eq!(prop_types(&closed), prop_types(&open));
652

            
653
        let differing: Vec<usize> = closed
654
            .as_ref()
655
            .iter()
656
            .zip(open.as_ref().iter())
657
            .enumerate()
658
            .filter_map(|(i, (c, o))| (c != o).then_some(i))
659
            .collect();
660

            
661
        assert_eq!(
662
            differing.len(),
663
            1,
664
            "exactly one declaration may differ between open and closed"
665
        );
666
        assert_eq!(
667
            closed.as_ref()[differing[0]].property.get_type(),
668
            display_ty(),
669
            "the only difference must be `display`"
670
        );
671
    }
672

            
673
    #[test]
674
    fn content_style_declares_display_exactly_once_and_correctly() {
675
        // A second `display` declaration would shadow the first and make the
676
        // open/closed state unobservable.
677
        assert_eq!(
678
            displays_in(&build_content_style(false)),
679
            alloc::vec![LayoutDisplay::None]
680
        );
681
        assert_eq!(
682
            displays_in(&build_content_style(true)),
683
            alloc::vec![LayoutDisplay::Block]
684
        );
685
    }
686

            
687
    #[test]
688
    fn content_style_has_no_duplicate_property_types() {
689
        for open in [false, true] {
690
            let mut types = prop_types(&build_content_style(open));
691
            let declared = types.len();
692
            assert!(declared > 0, "the panel style must not be empty");
693
            types.sort_unstable();
694
            types.dedup();
695
            assert_eq!(
696
                types.len(),
697
                declared,
698
                "a duplicated property type would make the later declaration silently win \
699
                 (open = {open})"
700
            );
701
        }
702
    }
703

            
704
    #[test]
705
    fn content_style_is_pure_and_unconditional() {
706
        for open in [false, true] {
707
            let a = build_content_style(open);
708
            let b = build_content_style(open);
709
            assert_eq!(a, b, "build_content_style must be a pure function of `open`");
710
            assert!(
711
                a.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty()),
712
                "the panel style must apply unconditionally — a stray condition would \
713
                 leave the panel unstyled (open = {open})"
714
            );
715
        }
716
    }
717

            
718
    #[test]
719
    fn content_style_carries_the_documented_geometry_in_both_states() {
720
        // The positioning props must be present whether the panel is open or
721
        // closed, otherwise the runtime `set_css_property(display)` toggle would
722
        // reveal an unpositioned panel.
723
        let expected = alloc::vec![
724
            CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
725
            CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(
726
                CONTENT_OFFSET_Y
727
            ))),
728
            CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
729
            CssPropertyWithConditions::simple(CssProperty::const_min_width(
730
                LayoutMinWidth::const_px(CONTENT_MIN_WIDTH)
731
            )),
732
        ];
733

            
734
        for open in [false, true] {
735
            let style = build_content_style(open);
736
            for e in &expected {
737
                assert!(
738
                    style.as_ref().contains(e),
739
                    "{:?} missing from the {} panel style",
740
                    e.property.get_type(),
741
                    if open { "open" } else { "closed" }
742
                );
743
            }
744
        }
745
    }
746

            
747
    // ------------------------------------------------------------------
748
    // Popover::new / Default
749
    // ------------------------------------------------------------------
750

            
751
    #[test]
752
    fn new_stores_both_doms_and_starts_closed() {
753
        let anchor = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("anchor"));
754
        let content = Dom::create_text_do_not_use_without_block_level_wrapper("panel");
755
        let pop = Popover::new(anchor.clone(), content.clone());
756

            
757
        assert_eq!(pop.anchor, anchor, "the anchor must be stored verbatim");
758
        assert_eq!(pop.content, content, "the content must be stored verbatim");
759
        assert!(
760
            !pop.popover_state.inner.open,
761
            "a fresh popover must start closed"
762
        );
763
        assert!(
764
            pop.popover_state.on_toggle.is_none(),
765
            "Popover::new sets no callback"
766
        );
767
        assert_eq!(
768
            pop.content_style,
769
            build_content_style(false),
770
            "content_style must match the closed state it was constructed with"
771
        );
772
        assert_eq!(
773
            pop.wrapper_style,
774
            CssPropertyWithConditionsVec::from_const_slice(POPOVER_WRAPPER_STYLE)
775
        );
776
    }
777

            
778
    #[test]
779
    fn default_equals_new_with_empty_doms() {
780
        assert_eq!(
781
            Popover::default(),
782
            Popover::new(Dom::default(), Dom::default())
783
        );
784
        assert!(!Popover::default().popover_state.inner.open);
785
    }
786

            
787
    #[test]
788
    fn new_survives_extreme_doms() {
789
        // a 128-deep anchor and a 2000-sibling panel: nothing may be truncated,
790
        // reordered or recursed into during construction.
791
        let mut deep = Dom::create_text_do_not_use_without_block_level_wrapper("leaf");
792
        for _ in 0..128 {
793
            deep = Dom::create_div().with_child(deep);
794
        }
795
        let wide_children: Vec<Dom> = (0..2000)
796
            .map(|i| Dom::create_text_do_not_use_without_block_level_wrapper(alloc::format!("{i}")))
797
            .collect();
798
        let wide = Dom::create_div().with_children(wide_children.clone().into());
799

            
800
        let pop = Popover::new(deep.clone(), wide.clone());
801

            
802
        assert_eq!(pop.anchor, deep);
803
        assert_eq!(pop.content, wide);
804
        assert_eq!(pop.content.children.as_ref().len(), 2000);
805
        assert!(!pop.popover_state.inner.open);
806
    }
807

            
808
    #[test]
809
    fn new_accepts_the_same_dom_as_anchor_and_content() {
810
        // aliasing the two arguments must produce two independent subtrees, not
811
        // one shared (and later doubly-mounted) node.
812
        let shared = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("x"));
813
        let pop = Popover::new(shared.clone(), shared.clone());
814

            
815
        assert_eq!(pop.anchor, shared);
816
        assert_eq!(pop.content, shared);
817

            
818
        let dom = pop.dom();
819
        let children = dom.children.as_ref();
820
        assert_eq!(text_of(&children[0].children.as_ref()[0].children.as_ref()[0]), Some("x"));
821
        assert_eq!(text_of(&children[1].children.as_ref()[0].children.as_ref()[0]), Some("x"));
822
    }
823

            
824
    // ------------------------------------------------------------------
825
    // set_open / with_open
826
    // ------------------------------------------------------------------
827

            
828
    #[test]
829
    fn set_open_round_trips_state_and_style() {
830
        let mut pop = Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("a"), Dom::create_text_do_not_use_without_block_level_wrapper("c"));
831

            
832
        // repeats and flips: the style must follow the flag on every write,
833
        // including redundant ones.
834
        for open in [true, false, false, true, true, false, true] {
835
            pop.set_open(open);
836
            assert_eq!(pop.popover_state.inner.open, open);
837
            assert_eq!(
838
                pop.content_style,
839
                build_content_style(open),
840
                "content_style desynced from the open flag"
841
            );
842
            assert_eq!(
843
                displays_in(&pop.content_style),
844
                alloc::vec![if open {
845
                    LayoutDisplay::Block
846
                } else {
847
                    LayoutDisplay::None
848
                }]
849
            );
850
        }
851

            
852
        // the restyle must not touch the payload doms
853
        assert_eq!(pop.anchor, Dom::create_text_do_not_use_without_block_level_wrapper("a"));
854
        assert_eq!(pop.content, Dom::create_text_do_not_use_without_block_level_wrapper("c"));
855
    }
856

            
857
    #[test]
858
    fn with_open_matches_set_open() {
859
        for open in [false, true] {
860
            let mut mutated = Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("a"), Dom::create_text_do_not_use_without_block_level_wrapper("c"));
861
            mutated.set_open(open);
862
            let built = Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("a"), Dom::create_text_do_not_use_without_block_level_wrapper("c")).with_open(open);
863
            assert_eq!(built, mutated, "builder and setter must agree (open = {open})");
864
        }
865
    }
866

            
867
    #[test]
868
    fn with_open_last_write_wins() {
869
        let base = Popover::new(Dom::create_div(), Dom::create_div());
870

            
871
        assert!(
872
            !base
873
                .clone()
874
                .with_open(true)
875
                .with_open(false)
876
                .popover_state
877
                .inner
878
                .open
879
        );
880
        assert!(
881
            base.clone()
882
                .with_open(false)
883
                .with_open(true)
884
                .popover_state
885
                .inner
886
                .open
887
        );
888
        assert!(
889
            base.clone()
890
                .with_open(true)
891
                .with_open(true)
892
                .popover_state
893
                .inner
894
                .open,
895
            "applying the same value twice must be idempotent"
896
        );
897

            
898
        // the *style* must follow the last write too, not the first
899
        assert_eq!(
900
            base.with_open(true).with_open(false).content_style,
901
            build_content_style(false)
902
        );
903
    }
904

            
905
    // ------------------------------------------------------------------
906
    // set_on_toggle / with_on_toggle
907
    // ------------------------------------------------------------------
908

            
909
    #[test]
910
    fn set_on_toggle_last_call_wins() {
911
        let mut pop = Popover::default();
912

            
913
        pop.set_on_toggle(RefAny::new(1u8), toggle_cb(toggle_do_nothing));
914
        assert!(pop.popover_state.on_toggle.is_some());
915

            
916
        // a second call must *replace* (not append / leak / panic)
917
        pop.set_on_toggle(RefAny::new(9i64), toggle_cb(record_toggle));
918
        let set = pop.popover_state.on_toggle.as_ref().expect("still Some");
919
        assert_eq!(set.refany.get_type_id(), RefAny::new(0i64).get_type_id());
920
        assert_eq!(set.callback, toggle_cb(record_toggle));
921
        assert_ne!(set.callback, toggle_cb(toggle_do_nothing));
922
    }
923

            
924
    #[test]
925
    fn set_on_toggle_does_not_disturb_state_style_or_doms() {
926
        let mut pop = Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("a"), Dom::create_text_do_not_use_without_block_level_wrapper("c")).with_open(true);
927
        let style_before = pop.content_style.clone();
928

            
929
        pop.set_on_toggle(RefAny::new(0u8), toggle_cb(toggle_do_nothing));
930

            
931
        assert!(pop.popover_state.inner.open, "open flag must survive");
932
        assert_eq!(pop.content_style, style_before, "style must survive");
933
        assert_eq!(pop.anchor, Dom::create_text_do_not_use_without_block_level_wrapper("a"));
934
        assert_eq!(pop.content, Dom::create_text_do_not_use_without_block_level_wrapper("c"));
935
    }
936

            
937
    #[test]
938
    fn with_on_toggle_matches_set_on_toggle() {
939
        let built = Popover::default().with_on_toggle(RefAny::new(7u32), toggle_cb(record_toggle));
940

            
941
        let mut mutated = Popover::default();
942
        mutated.set_on_toggle(RefAny::new(7u32), toggle_cb(record_toggle));
943

            
944
        assert!(built.popover_state.on_toggle.is_some());
945
        assert_eq!(
946
            built.popover_state.on_toggle.as_ref().unwrap().callback,
947
            mutated.popover_state.on_toggle.as_ref().unwrap().callback
948
        );
949
        // the builder form must not disturb the rest of the widget
950
        assert_eq!(built.anchor, Dom::default());
951
        assert!(!built.popover_state.inner.open);
952
        assert_eq!(built.content_style, build_content_style(false));
953
    }
954

            
955
    #[test]
956
    fn on_toggle_refany_is_shared_not_copied() {
957
        let mut shared = RefAny::new(ToggleLog { calls: Vec::new() });
958
        let pop = Popover::default().with_on_toggle(shared.clone(), toggle_cb(record_toggle));
959

            
960
        // a write through the widget's handle is visible through the caller's
961
        {
962
            let stored = pop.popover_state.on_toggle.as_ref().unwrap();
963
            let mut handle = stored.refany.clone();
964
            handle
965
                .downcast_mut::<ToggleLog>()
966
                .expect("payload type preserved")
967
                .calls
968
                .push(true);
969
        }
970

            
971
        assert_eq!(
972
            shared.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
973
            &[true]
974
        );
975
    }
976

            
977
    // ------------------------------------------------------------------
978
    // swap_with_default
979
    // ------------------------------------------------------------------
980

            
981
    #[test]
982
    fn swap_with_default_moves_all_state_out() {
983
        let mut pop = Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("a"), Dom::create_text_do_not_use_without_block_level_wrapper("c"))
984
            .with_open(true)
985
            .with_on_toggle(RefAny::new(5u8), toggle_cb(record_toggle));
986

            
987
        let original = pop.swap_with_default();
988

            
989
        assert_eq!(original.anchor, Dom::create_text_do_not_use_without_block_level_wrapper("a"));
990
        assert_eq!(original.content, Dom::create_text_do_not_use_without_block_level_wrapper("c"));
991
        assert!(original.popover_state.inner.open);
992
        assert!(original.popover_state.on_toggle.is_some());
993
        assert_eq!(original.content_style, build_content_style(true));
994

            
995
        assert_eq!(pop, Popover::default(), "self must be left as a default popover");
996
        assert!(
997
            pop.popover_state.on_toggle.is_none(),
998
            "self must lose the callback"
999
        );
        assert!(!pop.popover_state.inner.open, "self must be re-closed");
        assert_eq!(pop.content_style, build_content_style(false));
    }
    #[test]
    fn swap_with_default_twice_is_a_noop() {
        let mut pop = Popover::default();
        let first = pop.swap_with_default();
        assert_eq!(first, Popover::default());
        let second = pop.swap_with_default();
        assert_eq!(second, Popover::default());
        assert_eq!(pop, Popover::default());
    }
    // ------------------------------------------------------------------
    // Popover::dom
    // ------------------------------------------------------------------
    #[test]
    fn dom_structure_classes_and_callback() {
        let dom = Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("anchor"), Dom::create_text_do_not_use_without_block_level_wrapper("panel")).dom();
        assert!(has_class(&dom, "__azul-native-popover"));
        let children = dom.children.as_ref();
        assert_eq!(children.len(), 2, "the wrapper is exactly [trigger, panel]");
        let (trigger, panel) = (&children[0], &children[1]);
        assert!(has_class(trigger, "__azul-native-popover-trigger"));
        assert!(has_class(panel, "__azul-native-popover-content"));
        // the caller's doms are wrapped, not rewritten
        assert_eq!(text_of(&trigger.children.as_ref()[0]), Some("anchor"));
        assert_eq!(text_of(&panel.children.as_ref()[0]), Some("panel"));
        // the trigger is focusable and carries exactly one MouseUp handler
        assert!(matches!(trigger.root.get_tab_index(), Some(TabIndex::Auto)));
        let cbs = trigger.root.get_callbacks();
        assert_eq!(cbs.len(), 1);
        assert_eq!(
            cbs.as_ref()[0].event,
            EventFilter::Hover(HoverEventFilter::MouseUp)
        );
        assert_eq!(cbs.as_ref()[0].callback.cb, on_popover_toggle as usize);
        // the panel must NOT be clickable — the documented behaviour is that
        // clicking *inside* the panel does not close it.
        assert!(
            panel.root.get_callbacks().as_ref().is_empty(),
            "the panel must carry no callbacks"
        );
    }
    #[test]
    fn dom_panel_display_follows_open_state() {
        for open in [false, true] {
            let dom = Popover::new(Dom::create_div(), Dom::create_div())
                .with_open(open)
                .dom();
            let panel = &dom.children.as_ref()[1];
            assert_eq!(
                inline_display(panel),
                Some(if open {
                    LayoutDisplay::Block
                } else {
                    LayoutDisplay::None
                }),
                "the rendered panel's display must match the open flag"
            );
        }
    }
    #[test]
    fn dom_payload_is_the_popover_state() {
        for open in [false, true] {
            let dom = Popover::new(Dom::create_div(), Dom::create_div())
                .with_open(open)
                .dom();
            let mut payload = dom.children.as_ref()[0].root.get_callbacks().as_ref()[0]
                .refany
                .clone();
            let state = payload
                .downcast_ref::<PopoverStateWrapper>()
                .expect("the trigger payload must be a PopoverStateWrapper");
            assert_eq!(
                state.inner.open, open,
                "the trigger's payload must agree with the panel's display"
            );
            assert!(state.on_toggle.is_none(), "no user callback was set");
        }
    }
    #[test]
    fn dom_keeps_the_user_callback_payload_alive() {
        let log = RefAny::new(ToggleLog { calls: Vec::new() });
        let mut kept = log.clone();
        let dom = Popover::new(Dom::create_div(), Dom::create_div())
            .with_on_toggle(log, toggle_cb(record_toggle))
            .dom();
        let mut payload = dom.children.as_ref()[0].root.get_callbacks().as_ref()[0]
            .refany
            .clone();
        assert!(
            payload
                .downcast_ref::<PopoverStateWrapper>()
                .unwrap()
                .on_toggle
                .is_some(),
            "the user callback must survive the move into the trigger payload"
        );
        // ...and the caller's handle to the shared payload is still valid (no free)
        assert!(kept.downcast_ref::<ToggleLog>().unwrap().calls.is_empty());
    }
    #[test]
    fn each_dom_gets_its_own_state_refany() {
        let a = Popover::default().dom();
        let b = Popover::default().dom();
        let ra = a.children.as_ref()[0].root.get_callbacks().as_ref()[0]
            .refany
            .clone();
        let rb = b.children.as_ref()[0].root.get_callbacks().as_ref()[0]
            .refany
            .clone();
        assert_ne!(ra, rb, "two popovers must not share toggle state");
    }
    #[test]
    fn dom_child_count_cache_stays_consistent() {
        // deep + wide payloads: `estimated_total_children` must still equal the
        // real descendant count, otherwise the compact-DOM arena under-allocates
        // and panics later.
        let mut deep = Dom::create_text_do_not_use_without_block_level_wrapper("leaf");
        for _ in 0..64 {
            deep = Dom::create_div().with_child(deep);
        }
        let wide_children: Vec<Dom> = (0..256).map(|_| Dom::create_div()).collect();
        let wide = Dom::create_div().with_children(wide_children.into());
        let dom = Popover::new(deep, wide).dom();
        assert_eq!(
            dom.estimated_total_children,
            dom.recompute_estimated_total_children(),
            "cached descendant count desynced from the real tree"
        );
    }
    #[test]
    fn dom_of_default_popover_is_well_formed() {
        let dom = Popover::default().dom();
        assert!(has_class(&dom, "__azul-native-popover"));
        assert_eq!(dom.children.as_ref().len(), 2);
        assert_eq!(
            inline_display(&dom.children.as_ref()[1]),
            Some(LayoutDisplay::None),
            "a default popover renders a hidden panel"
        );
        assert_eq!(
            dom.estimated_total_children,
            dom.recompute_estimated_total_children()
        );
    }
    #[test]
    fn from_popover_for_dom_matches_dom_structurally() {
        // `Dom::from(p) == p.dom()` cannot be asserted directly: every `dom()`
        // call mints a fresh `RefAny` for the trigger payload, and two distinct
        // `RefAny`s never compare equal. Compare the observable structure.
        let make = || Popover::new(Dom::create_text_do_not_use_without_block_level_wrapper("a"), Dom::create_text_do_not_use_without_block_level_wrapper("c")).with_open(true);
        let via_from = Dom::from(make());
        let via_dom = make().dom();
        assert!(has_class(&via_from, "__azul-native-popover"));
        assert_eq!(via_from.children.as_ref().len(), 2);
        assert!(has_class(
            &via_from.children.as_ref()[0],
            "__azul-native-popover-trigger"
        ));
        assert!(has_class(
            &via_from.children.as_ref()[1],
            "__azul-native-popover-content"
        ));
        assert_eq!(
            inline_display(&via_from.children.as_ref()[1]),
            inline_display(&via_dom.children.as_ref()[1])
        );
        assert_eq!(
            via_from.children.as_ref()[1].children,
            via_dom.children.as_ref()[1].children
        );
        assert_eq!(
            via_from.estimated_total_children,
            via_dom.estimated_total_children
        );
    }
    // ------------------------------------------------------------------
    // on_popover_toggle
    // ------------------------------------------------------------------
    #[test]
    fn toggle_without_any_layout_result_is_a_noop() {
        let mut data = RefAny::new(PopoverStateWrapper::default());
        let (update, changes) = run_toggle(None, 0, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "nothing may be restyled without a panel");
        assert!(!payload_open(&mut data), "state must not flip");
    }
    #[test]
    fn toggle_without_next_sibling_does_not_flip_state() {
        // node 2 is the *last* child -> no next sibling -> early return, and
        // crucially `open` must NOT have been toggled.
        let mut data = RefAny::new(PopoverStateWrapper {
            inner: PopoverState { open: true },
            on_toggle: None.into(),
        });
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 2, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(payload_open(&mut data), "state must be untouched");
    }
    #[test]
    fn toggle_with_stale_hit_node_is_a_noop() {
        let mut data = RefAny::new(PopoverStateWrapper::default());
        // node 999 does not exist in the 3-node fixture
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 999, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(!payload_open(&mut data));
    }
    #[test]
    fn toggle_with_foreign_payload_is_a_noop() {
        // the callback-bearing node carries a RefAny of the *wrong* type
        let data = RefAny::new(0xdead_beef_u64);
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "a foreign payload must not restyle the panel"
        );
    }
    #[test]
    fn toggle_flips_state_and_panel_display() {
        let mut data = RefAny::new(PopoverStateWrapper::default());
        // closed -> open
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::Block)]
        );
        assert!(payload_open(&mut data));
        // open -> closed (same payload, so the flip must be stateful)
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::None)]
        );
        assert!(!payload_open(&mut data));
    }
    #[test]
    fn toggle_is_an_involution_over_many_clicks() {
        let mut data = RefAny::new(PopoverStateWrapper::default());
        for i in 0..8u32 {
            let (_, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
            let expected_open = i % 2 == 0;
            assert_eq!(
                display_writes(&changes),
                alloc::vec![(
                    2usize,
                    if expected_open {
                        LayoutDisplay::Block
                    } else {
                        LayoutDisplay::None
                    }
                )],
                "click {i} wrote the wrong display"
            );
            assert_eq!(payload_open(&mut data), expected_open);
        }
        // an even number of clicks returns to the initial state
        assert!(!payload_open(&mut data));
    }
    #[test]
    fn toggle_display_agrees_with_build_content_style() {
        // the runtime override and the static style must not disagree, otherwise
        // a rebuild would flip the panel back.
        let data = RefAny::new(PopoverStateWrapper::default());
        let (_, changes) = run_toggle(Some(trigger_panel_dom()), 1, data);
        assert_eq!(
            display_writes(&changes)
                .into_iter()
                .map(|(_, d)| d)
                .collect::<Vec<_>>(),
            displays_in(&build_content_style(true))
        );
    }
    #[test]
    fn toggle_invokes_user_callback_with_the_new_state() {
        let mut log = RefAny::new(ToggleLog { calls: Vec::new() });
        let data = RefAny::new(PopoverStateWrapper {
            inner: PopoverState { open: false },
            on_toggle: Some(PopoverOnToggle {
                callback: toggle_cb(record_toggle),
                refany: log.clone(),
            })
            .into(),
        });
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
        // the user's return value wins over the internal DoNothing
        assert_eq!(update, Update::RefreshDom);
        // ...and the panel is still restyled, even though the user callback ran
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::Block)]
        );
        assert_eq!(
            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
            &[true],
            "the callback must receive the *new* (post-flip) state"
        );
        // a second click reports the closed state
        let (_, _) = run_toggle(Some(trigger_panel_dom()), 1, data);
        assert_eq!(
            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
            &[true, false]
        );
    }
    #[test]
    fn toggle_still_restyles_when_the_user_callback_does_nothing() {
        let data = RefAny::new(PopoverStateWrapper {
            inner: PopoverState { open: false },
            on_toggle: Some(PopoverOnToggle {
                callback: toggle_cb(toggle_do_nothing),
                refany: RefAny::new(0u8),
            })
            .into(),
        });
        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::Block)],
            "the panel must be shown regardless of what the user callback returns"
        );
    }
    #[test]
    fn toggle_targets_the_panel_in_a_really_rendered_popover() {
        // End-to-end: the handler assumes "the hit trigger's next sibling is the
        // panel". Verify that against the DOM `Popover::dom()` actually builds,
        // rather than against a hand-made fixture.
        let styled =
            StyledDom::create_from_dom(Popover::new(Dom::create_div(), Dom::create_div()).dom());
        let trigger_idx = index_of_class(&styled, "__azul-native-popover-trigger");
        let panel_idx = index_of_class(&styled, "__azul-native-popover-content");
        let data = RefAny::new(PopoverStateWrapper::default());
        let (_, changes) = run_toggle(Some(styled), trigger_idx, data);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(panel_idx, LayoutDisplay::Block)],
            "the toggle must restyle the popover's own panel, not a stray sibling"
        );
    }
    #[test]
    fn toggle_on_the_wrapper_node_does_not_touch_the_panel() {
        // Clicking the *wrapper* (node 0, the root) must not flip anything: the
        // root has no next sibling.
        let styled =
            StyledDom::create_from_dom(Popover::new(Dom::create_div(), Dom::create_div()).dom());
        let wrapper_idx = index_of_class(&styled, "__azul-native-popover");
        let mut data = RefAny::new(PopoverStateWrapper::default());
        let (update, changes) = run_toggle(Some(styled), wrapper_idx, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(!payload_open(&mut data));
    }
}