Skip to main content

azul_layout/widgets/
popover.rs

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
29use azul_core::{
30    callbacks::{CoreCallbackData, Update},
31    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
32    refany::RefAny,
33};
34use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
35use 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
45use crate::callbacks::{Callback, CallbackInfo};
46
47static POPOVER_WRAPPER_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-popover"))];
49static POPOVER_TRIGGER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
50    "__azul-native-popover-trigger",
51))];
52static 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`.
59const CONTENT_OFFSET_Y: isize = 32;
60/// Minimum width of the floating panel.
61const CONTENT_MIN_WIDTH: isize = 160;
62const CONTENT_RADIUS: isize = 6;
63
64// ---- colours ----
65/// Panel background (white).
66const CONTENT_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
67/// Panel border (#cccccc).
68const 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.
72pub type PopoverOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, PopoverState) -> Update;
73impl_widget_callback!(
74    PopoverOnToggle,
75    OptionPopoverOnToggle,
76    PopoverOnToggleCallback,
77    PopoverOnToggleCallbackType
78);
79
80azul_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)]
96pub 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)]
111pub 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)]
121pub 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.
128static 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.
135static 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).
145fn build_content_style(open: bool) -> CssPropertyWithConditionsVec {
146    let display = if open {
147        LayoutDisplay::Block
148    } else {
149        LayoutDisplay::None
150    };
151    let bg_vec = StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(
152        CONTENT_BG_COLOR
153    )]);
154    CssPropertyWithConditionsVec::from_vec(alloc::vec![
155        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
156        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
157        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(
158            CONTENT_OFFSET_Y,
159        ))),
160        CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
161        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
162            CONTENT_MIN_WIDTH,
163        ))),
164        // padding: 8px
165        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
166            8,
167        ))),
168        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
169            LayoutPaddingBottom::const_px(8),
170        )),
171        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
172            LayoutPaddingLeft::const_px(8),
173        )),
174        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
175            LayoutPaddingRight::const_px(8),
176        )),
177        // border: 1px solid #cccccc
178        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
179            LayoutBorderTopWidth::const_px(1),
180        )),
181        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
182            LayoutBorderBottomWidth::const_px(1),
183        )),
184        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
185            LayoutBorderLeftWidth::const_px(1),
186        )),
187        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
188            LayoutBorderRightWidth::const_px(1),
189        )),
190        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
191            inner: BorderStyle::Solid,
192        })),
193        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
194            StyleBorderBottomStyle {
195                inner: BorderStyle::Solid,
196            },
197        )),
198        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
199            inner: BorderStyle::Solid,
200        })),
201        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
202            StyleBorderRightStyle {
203                inner: BorderStyle::Solid,
204            },
205        )),
206        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
207            inner: CONTENT_BORDER_COLOR,
208        })),
209        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
210            StyleBorderBottomColor {
211                inner: CONTENT_BORDER_COLOR,
212            },
213        )),
214        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
215            inner: CONTENT_BORDER_COLOR,
216        })),
217        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
218            StyleBorderRightColor {
219                inner: CONTENT_BORDER_COLOR,
220            },
221        )),
222        // border-radius: 6px
223        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
224            StyleBorderTopLeftRadius::const_px(CONTENT_RADIUS),
225        )),
226        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
227            StyleBorderTopRightRadius::const_px(CONTENT_RADIUS),
228        )),
229        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
230            StyleBorderBottomLeftRadius::const_px(CONTENT_RADIUS),
231        )),
232        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
233            StyleBorderBottomRightRadius::const_px(CONTENT_RADIUS),
234        )),
235        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
236    ])
237}
238
239impl Popover {
240    /// Creates a popover whose `anchor`, when clicked, toggles a panel holding
241    /// `content`. The panel starts closed.
242    #[must_use] pub fn new(anchor: Dom, content: Dom) -> Self {
243        Self {
244            popover_state: PopoverStateWrapper::default(),
245            anchor,
246            content,
247            wrapper_style: CssPropertyWithConditionsVec::from_const_slice(POPOVER_WRAPPER_STYLE),
248            content_style: build_content_style(false),
249        }
250    }
251
252    /// Sets whether the panel starts open, recomputing the panel style.
253    #[inline]
254    pub fn set_open(&mut self, open: bool) {
255        self.popover_state.inner.open = open;
256        self.content_style = build_content_style(open);
257    }
258
259    /// Builder-style setter for the initial open state.
260    #[inline]
261    #[must_use] pub fn with_open(mut self, open: bool) -> Self {
262        self.set_open(open);
263        self
264    }
265
266    /// Sets the toggle callback (invoked with the new state on every toggle).
267    #[inline]
268    pub fn set_on_toggle<C: Into<PopoverOnToggleCallback>>(&mut self, data: RefAny, on_toggle: C) {
269        self.popover_state.on_toggle = Some(PopoverOnToggle {
270            callback: on_toggle.into(),
271            refany: data,
272        })
273        .into();
274    }
275
276    /// Builder-style setter for the toggle callback.
277    #[inline]
278    #[must_use] pub fn with_on_toggle<C: Into<PopoverOnToggleCallback>>(
279        mut self,
280        data: RefAny,
281        on_toggle: C,
282    ) -> Self {
283        self.set_on_toggle(data, on_toggle);
284        self
285    }
286
287    /// Replaces `self` with a default (empty) popover and returns the original.
288    #[inline]
289    #[must_use] pub fn swap_with_default(&mut self) -> Self {
290        let mut s = Self::new(Dom::default(), Dom::default());
291        core::mem::swap(&mut s, self);
292        s
293    }
294
295    /// Renders the popover into a [`Dom`] subtree with the `__azul-native-popover`
296    /// class.
297    #[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        let trigger = Dom::create_div()
306            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_TRIGGER_CLASS))
307            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(POPOVER_TRIGGER_STYLE))
308            .with_tab_index(TabIndex::Auto)
309            .with_callbacks(
310                vec![CoreCallbackData {
311                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
312                    callback: CoreCallback {
313                        cb: on_popover_toggle as usize,
314                        ctx: OptionRefAny::None,
315                    },
316                    refany: RefAny::new(self.popover_state),
317                }]
318                .into(),
319            )
320            .with_children(vec![self.anchor].into());
321
322        let content = Dom::create_div()
323            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_CONTENT_CLASS))
324            .with_css_props(self.content_style)
325            .with_children(vec![self.content].into());
326
327        Dom::create_div()
328            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_WRAPPER_CLASS))
329            .with_css_props(self.wrapper_style)
330            // children: [trigger, content] — the panel is the trigger's next sibling.
331            .with_children(vec![trigger, content].into())
332    }
333}
334
335impl Default for Popover {
336    fn default() -> Self {
337        Self::new(Dom::default(), Dom::default())
338    }
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`.
345extern "C" fn on_popover_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
346    let trigger = info.get_hit_node();
347    let Some(content) = info.get_next_sibling(trigger) else {
348        return Update::DoNothing;
349    };
350
351    let (now_open, result) = {
352        let Some(mut pop) = data.downcast_mut::<PopoverStateWrapper>() else {
353            return Update::DoNothing;
354        };
355        pop.inner.open = !pop.inner.open;
356        let now_open = pop.inner.open;
357        let inner = pop.inner;
358        let pop = &mut *pop;
359        let result = match pop.on_toggle.as_mut() {
360            Some(PopoverOnToggle { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
361            None => Update::DoNothing,
362        };
363        (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    let display = if now_open {
370        LayoutDisplay::Block
371    } else {
372        LayoutDisplay::None
373    };
374    info.set_css_property(content, CssProperty::const_display(display));
375
376    result
377}
378
379impl From<Popover> for Dom {
380    fn from(p: Popover) -> Self {
381        p.dom()
382    }
383}
384
385#[cfg(test)]
386mod 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, IdOrClass::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, IdOrClass::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: 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("anchor"));
754        let content = Dom::create_text("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("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(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("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("a"), Dom::create_text("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("a"));
854        assert_eq!(pop.content, Dom::create_text("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("a"), Dom::create_text("c"));
861            mutated.set_open(open);
862            let built = Popover::new(Dom::create_text("a"), Dom::create_text("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("a"), Dom::create_text("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("a"));
934        assert_eq!(pop.content, Dom::create_text("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("a"), Dom::create_text("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("a"));
990        assert_eq!(original.content, Dom::create_text("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        );
1000        assert!(!pop.popover_state.inner.open, "self must be re-closed");
1001        assert_eq!(pop.content_style, build_content_style(false));
1002    }
1003
1004    #[test]
1005    fn swap_with_default_twice_is_a_noop() {
1006        let mut pop = Popover::default();
1007        let first = pop.swap_with_default();
1008        assert_eq!(first, Popover::default());
1009
1010        let second = pop.swap_with_default();
1011        assert_eq!(second, Popover::default());
1012        assert_eq!(pop, Popover::default());
1013    }
1014
1015    // ------------------------------------------------------------------
1016    // Popover::dom
1017    // ------------------------------------------------------------------
1018
1019    #[test]
1020    fn dom_structure_classes_and_callback() {
1021        let dom = Popover::new(Dom::create_text("anchor"), Dom::create_text("panel")).dom();
1022
1023        assert!(has_class(&dom, "__azul-native-popover"));
1024        let children = dom.children.as_ref();
1025        assert_eq!(children.len(), 2, "the wrapper is exactly [trigger, panel]");
1026
1027        let (trigger, panel) = (&children[0], &children[1]);
1028        assert!(has_class(trigger, "__azul-native-popover-trigger"));
1029        assert!(has_class(panel, "__azul-native-popover-content"));
1030
1031        // the caller's doms are wrapped, not rewritten
1032        assert_eq!(text_of(&trigger.children.as_ref()[0]), Some("anchor"));
1033        assert_eq!(text_of(&panel.children.as_ref()[0]), Some("panel"));
1034
1035        // the trigger is focusable and carries exactly one MouseUp handler
1036        assert!(matches!(trigger.root.get_tab_index(), Some(TabIndex::Auto)));
1037        let cbs = trigger.root.get_callbacks();
1038        assert_eq!(cbs.len(), 1);
1039        assert_eq!(
1040            cbs.as_ref()[0].event,
1041            EventFilter::Hover(HoverEventFilter::MouseUp)
1042        );
1043        assert_eq!(cbs.as_ref()[0].callback.cb, on_popover_toggle as usize);
1044
1045        // the panel must NOT be clickable — the documented behaviour is that
1046        // clicking *inside* the panel does not close it.
1047        assert!(
1048            panel.root.get_callbacks().as_ref().is_empty(),
1049            "the panel must carry no callbacks"
1050        );
1051    }
1052
1053    #[test]
1054    fn dom_panel_display_follows_open_state() {
1055        for open in [false, true] {
1056            let dom = Popover::new(Dom::create_div(), Dom::create_div())
1057                .with_open(open)
1058                .dom();
1059            let panel = &dom.children.as_ref()[1];
1060            assert_eq!(
1061                inline_display(panel),
1062                Some(if open {
1063                    LayoutDisplay::Block
1064                } else {
1065                    LayoutDisplay::None
1066                }),
1067                "the rendered panel's display must match the open flag"
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn dom_payload_is_the_popover_state() {
1074        for open in [false, true] {
1075            let dom = Popover::new(Dom::create_div(), Dom::create_div())
1076                .with_open(open)
1077                .dom();
1078            let mut payload = dom.children.as_ref()[0].root.get_callbacks().as_ref()[0]
1079                .refany
1080                .clone();
1081            let state = payload
1082                .downcast_ref::<PopoverStateWrapper>()
1083                .expect("the trigger payload must be a PopoverStateWrapper");
1084
1085            assert_eq!(
1086                state.inner.open, open,
1087                "the trigger's payload must agree with the panel's display"
1088            );
1089            assert!(state.on_toggle.is_none(), "no user callback was set");
1090        }
1091    }
1092
1093    #[test]
1094    fn dom_keeps_the_user_callback_payload_alive() {
1095        let log = RefAny::new(ToggleLog { calls: Vec::new() });
1096        let mut kept = log.clone();
1097
1098        let dom = Popover::new(Dom::create_div(), Dom::create_div())
1099            .with_on_toggle(log, toggle_cb(record_toggle))
1100            .dom();
1101
1102        let mut payload = dom.children.as_ref()[0].root.get_callbacks().as_ref()[0]
1103            .refany
1104            .clone();
1105        assert!(
1106            payload
1107                .downcast_ref::<PopoverStateWrapper>()
1108                .unwrap()
1109                .on_toggle
1110                .is_some(),
1111            "the user callback must survive the move into the trigger payload"
1112        );
1113
1114        // ...and the caller's handle to the shared payload is still valid (no free)
1115        assert!(kept.downcast_ref::<ToggleLog>().unwrap().calls.is_empty());
1116    }
1117
1118    #[test]
1119    fn each_dom_gets_its_own_state_refany() {
1120        let a = Popover::default().dom();
1121        let b = Popover::default().dom();
1122
1123        let ra = a.children.as_ref()[0].root.get_callbacks().as_ref()[0]
1124            .refany
1125            .clone();
1126        let rb = b.children.as_ref()[0].root.get_callbacks().as_ref()[0]
1127            .refany
1128            .clone();
1129
1130        assert_ne!(ra, rb, "two popovers must not share toggle state");
1131    }
1132
1133    #[test]
1134    fn dom_child_count_cache_stays_consistent() {
1135        // deep + wide payloads: `estimated_total_children` must still equal the
1136        // real descendant count, otherwise the compact-DOM arena under-allocates
1137        // and panics later.
1138        let mut deep = Dom::create_text("leaf");
1139        for _ in 0..64 {
1140            deep = Dom::create_div().with_child(deep);
1141        }
1142        let wide_children: Vec<Dom> = (0..256).map(|_| Dom::create_div()).collect();
1143        let wide = Dom::create_div().with_children(wide_children.into());
1144
1145        let dom = Popover::new(deep, wide).dom();
1146
1147        assert_eq!(
1148            dom.estimated_total_children,
1149            dom.recompute_estimated_total_children(),
1150            "cached descendant count desynced from the real tree"
1151        );
1152    }
1153
1154    #[test]
1155    fn dom_of_default_popover_is_well_formed() {
1156        let dom = Popover::default().dom();
1157        assert!(has_class(&dom, "__azul-native-popover"));
1158        assert_eq!(dom.children.as_ref().len(), 2);
1159        assert_eq!(
1160            inline_display(&dom.children.as_ref()[1]),
1161            Some(LayoutDisplay::None),
1162            "a default popover renders a hidden panel"
1163        );
1164        assert_eq!(
1165            dom.estimated_total_children,
1166            dom.recompute_estimated_total_children()
1167        );
1168    }
1169
1170    #[test]
1171    fn from_popover_for_dom_matches_dom_structurally() {
1172        // `Dom::from(p) == p.dom()` cannot be asserted directly: every `dom()`
1173        // call mints a fresh `RefAny` for the trigger payload, and two distinct
1174        // `RefAny`s never compare equal. Compare the observable structure.
1175        let make = || Popover::new(Dom::create_text("a"), Dom::create_text("c")).with_open(true);
1176        let via_from = Dom::from(make());
1177        let via_dom = make().dom();
1178
1179        assert!(has_class(&via_from, "__azul-native-popover"));
1180        assert_eq!(via_from.children.as_ref().len(), 2);
1181        assert!(has_class(
1182            &via_from.children.as_ref()[0],
1183            "__azul-native-popover-trigger"
1184        ));
1185        assert!(has_class(
1186            &via_from.children.as_ref()[1],
1187            "__azul-native-popover-content"
1188        ));
1189        assert_eq!(
1190            inline_display(&via_from.children.as_ref()[1]),
1191            inline_display(&via_dom.children.as_ref()[1])
1192        );
1193        assert_eq!(
1194            via_from.children.as_ref()[1].children,
1195            via_dom.children.as_ref()[1].children
1196        );
1197        assert_eq!(
1198            via_from.estimated_total_children,
1199            via_dom.estimated_total_children
1200        );
1201    }
1202
1203    // ------------------------------------------------------------------
1204    // on_popover_toggle
1205    // ------------------------------------------------------------------
1206
1207    #[test]
1208    fn toggle_without_any_layout_result_is_a_noop() {
1209        let mut data = RefAny::new(PopoverStateWrapper::default());
1210
1211        let (update, changes) = run_toggle(None, 0, data.clone());
1212
1213        assert_eq!(update, Update::DoNothing);
1214        assert!(changes.is_empty(), "nothing may be restyled without a panel");
1215        assert!(!payload_open(&mut data), "state must not flip");
1216    }
1217
1218    #[test]
1219    fn toggle_without_next_sibling_does_not_flip_state() {
1220        // node 2 is the *last* child -> no next sibling -> early return, and
1221        // crucially `open` must NOT have been toggled.
1222        let mut data = RefAny::new(PopoverStateWrapper {
1223            inner: PopoverState { open: true },
1224            on_toggle: None.into(),
1225        });
1226
1227        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 2, data.clone());
1228
1229        assert_eq!(update, Update::DoNothing);
1230        assert!(changes.is_empty());
1231        assert!(payload_open(&mut data), "state must be untouched");
1232    }
1233
1234    #[test]
1235    fn toggle_with_stale_hit_node_is_a_noop() {
1236        let mut data = RefAny::new(PopoverStateWrapper::default());
1237
1238        // node 999 does not exist in the 3-node fixture
1239        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 999, data.clone());
1240
1241        assert_eq!(update, Update::DoNothing);
1242        assert!(changes.is_empty());
1243        assert!(!payload_open(&mut data));
1244    }
1245
1246    #[test]
1247    fn toggle_with_foreign_payload_is_a_noop() {
1248        // the callback-bearing node carries a RefAny of the *wrong* type
1249        let data = RefAny::new(0xdead_beef_u64);
1250
1251        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
1252
1253        assert_eq!(update, Update::DoNothing);
1254        assert!(
1255            changes.is_empty(),
1256            "a foreign payload must not restyle the panel"
1257        );
1258    }
1259
1260    #[test]
1261    fn toggle_flips_state_and_panel_display() {
1262        let mut data = RefAny::new(PopoverStateWrapper::default());
1263
1264        // closed -> open
1265        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
1266        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
1267        assert_eq!(
1268            display_writes(&changes),
1269            alloc::vec![(2usize, LayoutDisplay::Block)]
1270        );
1271        assert!(payload_open(&mut data));
1272
1273        // open -> closed (same payload, so the flip must be stateful)
1274        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
1275        assert_eq!(update, Update::DoNothing);
1276        assert_eq!(
1277            display_writes(&changes),
1278            alloc::vec![(2usize, LayoutDisplay::None)]
1279        );
1280        assert!(!payload_open(&mut data));
1281    }
1282
1283    #[test]
1284    fn toggle_is_an_involution_over_many_clicks() {
1285        let mut data = RefAny::new(PopoverStateWrapper::default());
1286
1287        for i in 0..8u32 {
1288            let (_, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
1289            let expected_open = i % 2 == 0;
1290            assert_eq!(
1291                display_writes(&changes),
1292                alloc::vec![(
1293                    2usize,
1294                    if expected_open {
1295                        LayoutDisplay::Block
1296                    } else {
1297                        LayoutDisplay::None
1298                    }
1299                )],
1300                "click {i} wrote the wrong display"
1301            );
1302            assert_eq!(payload_open(&mut data), expected_open);
1303        }
1304
1305        // an even number of clicks returns to the initial state
1306        assert!(!payload_open(&mut data));
1307    }
1308
1309    #[test]
1310    fn toggle_display_agrees_with_build_content_style() {
1311        // the runtime override and the static style must not disagree, otherwise
1312        // a rebuild would flip the panel back.
1313        let data = RefAny::new(PopoverStateWrapper::default());
1314        let (_, changes) = run_toggle(Some(trigger_panel_dom()), 1, data);
1315
1316        assert_eq!(
1317            display_writes(&changes)
1318                .into_iter()
1319                .map(|(_, d)| d)
1320                .collect::<Vec<_>>(),
1321            displays_in(&build_content_style(true))
1322        );
1323    }
1324
1325    #[test]
1326    fn toggle_invokes_user_callback_with_the_new_state() {
1327        let mut log = RefAny::new(ToggleLog { calls: Vec::new() });
1328        let data = RefAny::new(PopoverStateWrapper {
1329            inner: PopoverState { open: false },
1330            on_toggle: Some(PopoverOnToggle {
1331                callback: toggle_cb(record_toggle),
1332                refany: log.clone(),
1333            })
1334            .into(),
1335        });
1336
1337        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data.clone());
1338
1339        // the user's return value wins over the internal DoNothing
1340        assert_eq!(update, Update::RefreshDom);
1341        // ...and the panel is still restyled, even though the user callback ran
1342        assert_eq!(
1343            display_writes(&changes),
1344            alloc::vec![(2usize, LayoutDisplay::Block)]
1345        );
1346        assert_eq!(
1347            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
1348            &[true],
1349            "the callback must receive the *new* (post-flip) state"
1350        );
1351
1352        // a second click reports the closed state
1353        let (_, _) = run_toggle(Some(trigger_panel_dom()), 1, data);
1354        assert_eq!(
1355            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
1356            &[true, false]
1357        );
1358    }
1359
1360    #[test]
1361    fn toggle_still_restyles_when_the_user_callback_does_nothing() {
1362        let data = RefAny::new(PopoverStateWrapper {
1363            inner: PopoverState { open: false },
1364            on_toggle: Some(PopoverOnToggle {
1365                callback: toggle_cb(toggle_do_nothing),
1366                refany: RefAny::new(0u8),
1367            })
1368            .into(),
1369        });
1370
1371        let (update, changes) = run_toggle(Some(trigger_panel_dom()), 1, data);
1372
1373        assert_eq!(update, Update::DoNothing);
1374        assert_eq!(
1375            display_writes(&changes),
1376            alloc::vec![(2usize, LayoutDisplay::Block)],
1377            "the panel must be shown regardless of what the user callback returns"
1378        );
1379    }
1380
1381    #[test]
1382    fn toggle_targets_the_panel_in_a_really_rendered_popover() {
1383        // End-to-end: the handler assumes "the hit trigger's next sibling is the
1384        // panel". Verify that against the DOM `Popover::dom()` actually builds,
1385        // rather than against a hand-made fixture.
1386        let styled =
1387            StyledDom::create_from_dom(Popover::new(Dom::create_div(), Dom::create_div()).dom());
1388        let trigger_idx = index_of_class(&styled, "__azul-native-popover-trigger");
1389        let panel_idx = index_of_class(&styled, "__azul-native-popover-content");
1390
1391        let data = RefAny::new(PopoverStateWrapper::default());
1392        let (_, changes) = run_toggle(Some(styled), trigger_idx, data);
1393
1394        assert_eq!(
1395            display_writes(&changes),
1396            alloc::vec![(panel_idx, LayoutDisplay::Block)],
1397            "the toggle must restyle the popover's own panel, not a stray sibling"
1398        );
1399    }
1400
1401    #[test]
1402    fn toggle_on_the_wrapper_node_does_not_touch_the_panel() {
1403        // Clicking the *wrapper* (node 0, the root) must not flip anything: the
1404        // root has no next sibling.
1405        let styled =
1406            StyledDom::create_from_dom(Popover::new(Dom::create_div(), Dom::create_div()).dom());
1407        let wrapper_idx = index_of_class(&styled, "__azul-native-popover");
1408
1409        let mut data = RefAny::new(PopoverStateWrapper::default());
1410        let (update, changes) = run_toggle(Some(styled), wrapper_idx, data.clone());
1411
1412        assert_eq!(update, Update::DoNothing);
1413        assert!(changes.is_empty());
1414        assert!(!payload_open(&mut data));
1415    }
1416}