Skip to main content

azul_layout/widgets/
drop_down.rs

1//! Native drop-down / select widget.
2//!
3//! Renders a clickable trigger (label + arrow icon) that opens a native
4//! menu popup for item selection.  Depends on [`azul_core::menu`] for
5//! popup rendering.
6
7use azul_core::{
8    callbacks::{CoreCallback, CoreCallbackData, Update},
9    dom::{
10        Dom, DomVec, EventFilter, FocusEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec,
11        TabIndex,
12    },
13    menu::{Menu, MenuItem, MenuPopupPosition, StringMenuItem},
14    refany::RefAny,
15    window::ContextMenuMouseButton,
16};
17#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
18use azul_css::{
19    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
20    props::{
21        basic::{
22            color::{ColorU, ColorOrSystem},
23            font::{StyleFontFamily, StyleFontFamilyVec},
24            *,
25        },
26        layout::*,
27        property::CssProperty,
28        style::*,
29    },
30    *,
31};
32
33use crate::callbacks::{Callback, CallbackInfo};
34
35// -- Callback type via macro --
36
37/// Callback signature invoked when the user selects a new choice.
38///
39/// The `usize` argument is the zero-based index of the chosen item.
40pub type DropDownOnChoiceChangeCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
41impl_widget_callback!(
42    DropDownOnChoiceChange,
43    OptionDropDownOnChoiceChange,
44    DropDownOnChoiceChangeCallback,
45    DropDownOnChoiceChangeCallbackType
46);
47
48azul_core::impl_managed_callback! {
49    wrapper:        DropDownOnChoiceChangeCallback,
50    info_ty:        CallbackInfo,
51    return_ty:      Update,
52    default_ret:    Update::DoNothing,
53    invoker_static: DROP_DOWN_ON_CHOICE_CHANGE_INVOKER,
54    invoker_ty:     AzDropDownOnChoiceChangeCallbackInvoker,
55    thunk_fn:       az_drop_down_on_choice_change_callback_thunk,
56    setter_fn:      AzApp_setDropDownOnChoiceChangeCallbackInvoker,
57    from_handle_fn: AzDropDownOnChoiceChangeCallback_createFromHostHandle,
58    extra_args:     [ choice_index: usize ],
59}
60
61// -- Font --
62
63const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
64const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
65const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
66    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
67
68// -- Layout constants --
69
70const FONT_SIZE_PX: isize = 13;
71const ARROW_FONT_SIZE_PX: isize = 18;
72const PADDING_HORIZONTAL_PX: isize = 4;
73const PADDING_VERTICAL_PX: isize = 2;
74const LABEL_PADDING_RIGHT_PX: isize = 8;
75const BORDER_WIDTH_PX: isize = 1;
76
77// -- Colors --
78
79const BORDER_NORMAL: ColorU = ColorU { r: 172, g: 172, b: 172, a: 255 };
80const BORDER_HOVER: ColorU = ColorU { r: 126, g: 180, b: 234, a: 255 };
81const BORDER_FOCUS: ColorU = ColorU { r: 86, g: 157, b: 229, a: 255 };
82
83const BG_GRADIENT_TOP: ColorU = ColorU { r: 245, g: 245, b: 245, a: 255 };
84const BG_GRADIENT_BOTTOM: ColorU = ColorU { r: 235, g: 235, b: 235, a: 255 };
85const BG_HOVER_TOP: ColorU = ColorU { r: 234, g: 244, b: 252, a: 255 };
86const BG_HOVER_BOTTOM: ColorU = ColorU { r: 218, g: 236, b: 252, a: 255 };
87const BG_ACTIVE_TOP: ColorU = ColorU { r: 218, g: 236, b: 252, a: 255 };
88const BG_ACTIVE_BOTTOM: ColorU = ColorU { r: 202, g: 226, b: 248, a: 255 };
89
90const NORMAL_BG_ITEMS: &[StyleBackgroundContent] =
91    &[StyleBackgroundContent::LinearGradient(LinearGradient {
92        direction: Direction::FromTo(DirectionCorners {
93            dir_from: DirectionCorner::Top,
94            dir_to: DirectionCorner::Bottom,
95        }),
96        extend_mode: ExtendMode::Clamp,
97        stops: NormalizedLinearColorStopVec::from_const_slice(&[
98            NormalizedLinearColorStop {
99                offset: PercentageValue::const_new(0),
100                color: ColorOrSystem::color(BG_GRADIENT_TOP),
101            },
102            NormalizedLinearColorStop {
103                offset: PercentageValue::const_new(100),
104                color: ColorOrSystem::color(BG_GRADIENT_BOTTOM),
105            },
106        ]),
107    })];
108
109const HOVER_BG_ITEMS: &[StyleBackgroundContent] =
110    &[StyleBackgroundContent::LinearGradient(LinearGradient {
111        direction: Direction::FromTo(DirectionCorners {
112            dir_from: DirectionCorner::Top,
113            dir_to: DirectionCorner::Bottom,
114        }),
115        extend_mode: ExtendMode::Clamp,
116        stops: NormalizedLinearColorStopVec::from_const_slice(&[
117            NormalizedLinearColorStop {
118                offset: PercentageValue::const_new(0),
119                color: ColorOrSystem::color(BG_HOVER_TOP),
120            },
121            NormalizedLinearColorStop {
122                offset: PercentageValue::const_new(100),
123                color: ColorOrSystem::color(BG_HOVER_BOTTOM),
124            },
125        ]),
126    })];
127
128const ACTIVE_BG_ITEMS: &[StyleBackgroundContent] =
129    &[StyleBackgroundContent::LinearGradient(LinearGradient {
130        direction: Direction::FromTo(DirectionCorners {
131            dir_from: DirectionCorner::Top,
132            dir_to: DirectionCorner::Bottom,
133        }),
134        extend_mode: ExtendMode::Clamp,
135        stops: NormalizedLinearColorStopVec::from_const_slice(&[
136            NormalizedLinearColorStop {
137                offset: PercentageValue::const_new(0),
138                color: ColorOrSystem::color(BG_ACTIVE_TOP),
139            },
140            NormalizedLinearColorStop {
141                offset: PercentageValue::const_new(100),
142                color: ColorOrSystem::color(BG_ACTIVE_BOTTOM),
143            },
144        ]),
145    })];
146
147// -- Dropdown wrapper styles (the clickable trigger) --
148
149static DROPDOWN_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
150    // Layout
151    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineFlex)),
152    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
153    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
154    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
155    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
156    // Font
157    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(FONT_SIZE_PX))),
158    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
159    // Padding
160    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(PADDING_HORIZONTAL_PX))),
161    CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(PADDING_HORIZONTAL_PX))),
162    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(PADDING_VERTICAL_PX))),
163    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(PADDING_VERTICAL_PX))),
164    // Border
165    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(LayoutBorderTopWidth::const_px(BORDER_WIDTH_PX))),
166    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(LayoutBorderBottomWidth::const_px(BORDER_WIDTH_PX))),
167    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(LayoutBorderLeftWidth::const_px(BORDER_WIDTH_PX))),
168    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(LayoutBorderRightWidth::const_px(BORDER_WIDTH_PX))),
169    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle { inner: BorderStyle::Solid })),
170    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(StyleBorderBottomStyle { inner: BorderStyle::Solid })),
171    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle { inner: BorderStyle::Solid })),
172    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(StyleBorderRightStyle { inner: BorderStyle::Solid })),
173    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor { inner: BORDER_NORMAL })),
174    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(StyleBorderBottomColor { inner: BORDER_NORMAL })),
175    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor { inner: BORDER_NORMAL })),
176    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(StyleBorderRightColor { inner: BORDER_NORMAL })),
177    // Background
178    CssPropertyWithConditions::simple(CssProperty::const_background_content(
179        StyleBackgroundContentVec::from_const_slice(NORMAL_BG_ITEMS),
180    )),
181    // Hover
182    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor { inner: BORDER_HOVER })),
183    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(StyleBorderBottomColor { inner: BORDER_HOVER })),
184    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(StyleBorderLeftColor { inner: BORDER_HOVER })),
185    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(StyleBorderRightColor { inner: BORDER_HOVER })),
186    CssPropertyWithConditions::on_hover(CssProperty::const_background_content(
187        StyleBackgroundContentVec::from_const_slice(HOVER_BG_ITEMS),
188    )),
189    // Active
190    CssPropertyWithConditions::on_active(CssProperty::const_background_content(
191        StyleBackgroundContentVec::from_const_slice(ACTIVE_BG_ITEMS),
192    )),
193    // Focus
194    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor { inner: BORDER_FOCUS })),
195    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(StyleBorderBottomColor { inner: BORDER_FOCUS })),
196    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(StyleBorderLeftColor { inner: BORDER_FOCUS })),
197    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(StyleBorderRightColor { inner: BORDER_FOCUS })),
198];
199
200// -- Label text style --
201
202static DROPDOWN_LABEL_STYLE: &[CssPropertyWithConditions] = &[
203    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
204    CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(LABEL_PADDING_RIGHT_PX))),
205];
206
207// -- Arrow icon style --
208
209static DROPDOWN_ARROW_ICON_STYLE: &[CssPropertyWithConditions] = &[
210    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(ARROW_FONT_SIZE_PX))),
211    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
212];
213
214// ============================================================================
215// Widget struct and API
216// ============================================================================
217
218/// A drop-down / select widget that displays the currently selected item
219/// and opens a native menu popup when focused.
220#[derive(Debug, Clone, PartialEq, Eq)]
221#[repr(C)]
222pub struct DropDown {
223    /// The list of choices presented in the popup menu.
224    pub choices: StringVec,
225    /// Zero-based index of the currently selected choice.
226    pub selected: usize,
227    /// Optional callback invoked when the user picks a different choice.
228    pub on_choice_change: OptionDropDownOnChoiceChange,
229}
230
231impl Default for DropDown {
232    fn default() -> Self {
233        Self {
234            choices: StringVec::from_const_slice(&[]),
235            selected: 0,
236            on_choice_change: None.into(),
237        }
238    }
239}
240
241impl DropDown {
242    /// Creates a new `DropDown` with the given choices and no callback.
243    #[must_use] pub fn new(choices: StringVec) -> Self {
244        Self {
245            choices,
246            selected: 0,
247            on_choice_change: None.into(),
248        }
249    }
250
251    /// Sets the callback invoked when the user selects a different choice.
252    pub fn set_on_choice_change<C: Into<DropDownOnChoiceChangeCallback>>(&mut self, data: RefAny, callback: C) {
253        self.on_choice_change = Some(DropDownOnChoiceChange {
254            callback: callback.into(),
255            refany: data,
256        }).into();
257    }
258
259    /// Builder variant of [`Self::set_on_choice_change`].
260    #[must_use]
261    pub fn with_on_choice_change<C: Into<DropDownOnChoiceChangeCallback>>(mut self, data: RefAny, callback: C) -> Self {
262        self.set_on_choice_change(data, callback);
263        self
264    }
265
266    /// Replaces `self` with the default value and returns the original.
267    #[must_use]
268    pub fn swap_with_default(&mut self) -> Self {
269        let mut m = Self::default();
270        core::mem::swap(&mut m, self);
271        m
272    }
273
274    /// Builds the DOM tree for this drop-down widget.
275    #[must_use] pub fn dom(self) -> Dom {
276        const DROPDOWN_CLASS: &[IdOrClass] =
277            &[Class(AzString::from_const_str("__azul-native-dropdown"))];
278
279        let selected_text = self.choices
280            .as_slice()
281            .get(self.selected)
282            .cloned()
283            .unwrap_or_else(|| AzString::from_const_str(""));
284
285        let refany = RefAny::new(self);
286
287        // Wrapper: focusable trigger that opens popup on focus
288        
289
290        Dom::create_div()
291            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DROPDOWN_WRAPPER_STYLE))
292            .with_ids_and_classes(IdOrClassVec::from_const_slice(DROPDOWN_CLASS))
293            .with_tab_index(TabIndex::Auto)
294            .with_callbacks(
295                vec![CoreCallbackData {
296                    event: EventFilter::Focus(FocusEventFilter::FocusReceived),
297                    refany,
298                    callback: CoreCallback {
299                        cb: on_dropdown_click as usize,
300                        ctx: azul_core::refany::OptionRefAny::None,
301                    },
302                }]
303                .into(),
304            )
305            .with_children(DomVec::from_vec(vec![
306                // Selected text label wrapped in <p> for proper block formatting
307                Dom::create_p()
308                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DROPDOWN_LABEL_STYLE))
309                    .with_children(DomVec::from_vec(vec![
310                        Dom::create_text(selected_text),
311                    ])),
312                // Arrow icon (resolved via Material Icons)
313                Dom::create_icon(AzString::from_const_str("arrow_drop_down"))
314                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DROPDOWN_ARROW_ICON_STYLE)),
315            ]))
316    }
317}
318
319// ============================================================================
320// Internal callback data types
321// ============================================================================
322
323struct ChoiceCallbackData {
324    choice_id: usize,
325    on_choice_change: OptionDropDownOnChoiceChange,
326}
327
328// ============================================================================
329// Callbacks
330// ============================================================================
331
332extern "C" fn on_dropdown_click(mut refany: RefAny, mut info: CallbackInfo) -> Update {
333    let Some(refany) = refany.downcast_ref::<DropDown>() else {
334        return Update::DoNothing;
335    };
336
337    let menu_items: Vec<MenuItem> = refany
338        .choices
339        .iter()
340        .enumerate()
341        .map(|(idx, choice)| {
342            MenuItem::String(StringMenuItem::create(choice.clone()).with_callback(
343                RefAny::new(ChoiceCallbackData {
344                    choice_id: idx,
345                    on_choice_change: refany.on_choice_change.clone(),
346                }),
347                on_choice_selected as usize,
348            ))
349        })
350        .collect();
351
352    let menu = Menu {
353        items: menu_items.into(),
354        position: MenuPopupPosition::BottomOfHitRect,
355        context_mouse_btn: ContextMenuMouseButton::Right,
356    };
357
358    info.open_menu_for_hit_node(menu);
359    Update::DoNothing
360}
361
362extern "C" fn on_choice_selected(mut refany: RefAny, info: CallbackInfo) -> Update {
363    let Some(mut refany) = refany.downcast_mut::<ChoiceCallbackData>() else {
364        return Update::DoNothing;
365    };
366
367    let choice_id = refany.choice_id;
368
369    match refany.on_choice_change.as_mut() {
370        Some(DropDownOnChoiceChange { refany, callback }) => {
371            (callback.cb)(refany.clone(), info, choice_id)
372        }
373        None => Update::DoNothing,
374    }
375}
376
377impl From<DropDown> for Dom {
378    fn from(b: DropDown) -> Self {
379        b.dom()
380    }
381}
382
383#[cfg(test)]
384mod autotest_generated {
385    use std::{
386        collections::{BTreeMap, HashMap},
387        sync::{Arc, Mutex},
388    };
389
390    use azul_core::{
391        dom::{DomId, DomNodeId, NodeId, NodeType},
392        geom::{LogicalPosition, LogicalRect, LogicalSize, OptionLogicalPosition},
393        gl::OptionGlContextPtr,
394        hit_test::ScrollPosition,
395        refany::OptionRefAny,
396        resources::RendererResources,
397        styled_dom::{NodeHierarchyItemId, StyledDom},
398        window::{MonitorVec, RawWindowHandle},
399    };
400    use rust_fontconfig::FcFontCache;
401
402    use super::*;
403    #[cfg(feature = "icu")]
404    use crate::icu::IcuLocalizerHandle;
405    use crate::{
406        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
407        solver3::{
408            display_list::{DisplayList, DisplayListItem, WindowLogicalRect},
409            layout_tree::LayoutTree,
410        },
411        window::{DomLayoutResult, LayoutWindow},
412        window_state::FullWindowState,
413    };
414
415    // ------------------------------------------------------------------
416    // Fixtures
417    // ------------------------------------------------------------------
418
419    /// Where every user callback records the index it was handed. Passed through
420    /// the widget as a `RefAny`, so the assertions exercise the real data plumbing
421    /// (`RefAny::new` -> clone -> `downcast_ref`) rather than a side channel.
422    type ChoiceLog = Arc<Mutex<Vec<usize>>>;
423
424    /// Offset added by `reject_choice` so the two recorders below stay
425    /// distinguishable in the log.
426    const SENTINEL: usize = 1_000_000;
427
428    extern "C" fn record_choice(mut data: RefAny, _info: CallbackInfo, choice_index: usize) -> Update {
429        if let Some(log) = data.downcast_ref::<ChoiceLog>() {
430            log.lock().expect("choice log poisoned").push(choice_index);
431        }
432        Update::RefreshDom
433    }
434
435    /// A second callback with a *deliberately different body*: two identical
436    /// `extern "C"` bodies are legal prey for identical-code folding, which would
437    /// merge their addresses and make the "last write wins" assertion vacuous.
438    extern "C" fn reject_choice(mut data: RefAny, _info: CallbackInfo, choice_index: usize) -> Update {
439        if let Some(log) = data.downcast_ref::<ChoiceLog>() {
440            log.lock()
441                .expect("choice log poisoned")
442                .push(choice_index.wrapping_add(SENTINEL));
443        }
444        Update::RefreshDomAllWindows
445    }
446
447    fn log() -> ChoiceLog {
448        Arc::new(Mutex::new(Vec::new()))
449    }
450
451    fn entries(log: &ChoiceLog) -> Vec<usize> {
452        log.lock().expect("choice log poisoned").clone()
453    }
454
455    fn cb(f: DropDownOnChoiceChangeCallbackType) -> DropDownOnChoiceChangeCallback {
456        DropDownOnChoiceChangeCallback::from(f)
457    }
458
459    fn choices(items: &[&str]) -> StringVec {
460        StringVec::from_vec(
461            items
462                .iter()
463                .map(|s| AzString::from_string((*s).to_string()))
464                .collect(),
465        )
466    }
467
468    /// Adversarial choice labels: empty, whitespace, combining marks, ZWJ emoji,
469    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
470    /// truncate), bidi overrides, control characters, and strings that collide
471    /// with the widget's own class / icon names.
472    fn adversarial_choices() -> Vec<String> {
473        let mut v: Vec<String> = [
474            "",
475            " ",
476            "OK",
477            "e\u{0301}",                                   // e + combining acute
478            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
479            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
480            "\0",                                          // a lone NUL
481            "a\0b",                                        // embedded NUL
482            "\u{FFFD}\u{202E}\u{200B}",                    // replacement, RTL override, ZWSP
483            "…\t\r\n",                                     // control chars in a label
484            "__azul-native-dropdown",                      // looks like the widget's own class
485            "arrow_drop_down",                             // looks like the widget's own icon
486        ]
487        .iter()
488        .map(|s| (*s).to_string())
489        .collect();
490        v.push("x".repeat(100_000));
491        v
492    }
493
494    fn adversarial_dropdown() -> DropDown {
495        DropDown::new(StringVec::from_vec(
496            adversarial_choices().into_iter().map(AzString::from_string).collect(),
497        ))
498    }
499
500    // ------------------------------------------------------------------
501    // DOM probes
502    // ------------------------------------------------------------------
503
504    /// The text the trigger displays: `root > p > text`. Panics loudly (rather
505    /// than returning `None`) if the shape ever changes, because every label
506    /// assertion below silently depends on that shape.
507    fn label_of(dom: &Dom) -> &str {
508        let p = dom
509            .children
510            .as_ref()
511            .first()
512            .expect("the trigger must have a label child");
513        let text = p
514            .children
515            .as_ref()
516            .first()
517            .expect("the label must wrap a text node");
518        match text.root.get_node_type() {
519            NodeType::Text(s) => s.as_ref().as_str(),
520            other => panic!("expected a text node, got {other:?}"),
521        }
522    }
523
524    fn classes(dom: &Dom) -> Vec<String> {
525        dom.root
526            .get_ids_and_classes()
527            .as_ref()
528            .iter()
529            .filter_map(|c| match c {
530                IdOrClass::Class(s) => Some(s.as_str().to_string()),
531                IdOrClass::Id(_) => None,
532            })
533            .collect()
534    }
535
536    /// The recursive descendant count. `Dom::estimated_total_children` is a
537    /// *cached* value that, if too small, makes `convert_dom_into_compact_dom`
538    /// under-allocate its arenas and panic on out-of-bounds writes.
539    fn count_descendants(dom: &Dom) -> usize {
540        dom.children
541            .as_ref()
542            .iter()
543            .map(|c| 1 + count_descendants(c))
544            .sum()
545    }
546
547    /// Renders `dd`, then hands back both the DOM *and* the very `RefAny` the
548    /// widget registered on its own focus callback. Driving `on_dropdown_click`
549    /// with that `RefAny` is the real wiring — nothing is re-created by hand, so
550    /// a mismatch between what `dom()` stores and what the handler expects
551    /// cannot hide behind the fixture.
552    fn rendered(dd: DropDown) -> (Dom, RefAny) {
553        let dom = dd.dom();
554        let refany = dom.root.callbacks.as_ref()[0].refany.clone();
555        (dom, refany)
556    }
557
558    // ------------------------------------------------------------------
559    // CallbackInfo harness (mirrors the one in `check_box.rs` / `timer.rs`)
560    // ------------------------------------------------------------------
561
562    struct Env<'a> {
563        ref_data: &'a CallbackInfoRefData<'a>,
564        changes: &'a Arc<Mutex<Vec<CallbackChange>>>,
565        hit: DomNodeId,
566    }
567
568    impl Env<'_> {
569        fn info(&self) -> CallbackInfo {
570            CallbackInfo::new(
571                self.ref_data,
572                self.changes,
573                self.hit,
574                OptionLogicalPosition::None,
575                OptionLogicalPosition::None,
576            )
577        }
578
579        fn take_changes(&self) -> Vec<CallbackChange> {
580            self.changes
581                .lock()
582                .map(|mut c| core::mem::take(&mut *c))
583                .unwrap_or_default()
584        }
585
586        fn take_one(&self) -> CallbackChange {
587            let mut changes = self.take_changes();
588            assert_eq!(changes.len(), 1, "expected exactly one change: {changes:?}");
589            changes.remove(0)
590        }
591    }
592
593    /// The tag the hit-tester would use for `node`. `open_menu_for_node` resolves
594    /// the anchor rect through this mapping, so a forged hit-test area must reuse
595    /// the id the styling pass actually assigned.
596    fn tag_of(styled_dom: &StyledDom, node: NodeId) -> u64 {
597        let nid = NodeHierarchyItemId::from_crate_internal(Some(node));
598        styled_dom
599            .tag_ids_to_node_ids
600            .iter()
601            .find(|m| m.node_id == nid)
602            .expect("the dropdown trigger must be hit-testable")
603            .tag_id
604            .inner
605    }
606
607    /// A `DomLayoutResult` carrying only a `styled_dom` plus (optionally) one
608    /// forged hit-test area. The dropdown handler reaches exactly one geometry
609    /// query (`get_node_hit_test_bounds`), which reads the display list only —
610    /// no real layout (and no font) is needed.
611    fn layout_result(styled_dom: StyledDom, anchor: Option<(NodeId, LogicalRect)>) -> DomLayoutResult {
612        let mut display_list = DisplayList::default();
613        if let Some((node, rect)) = anchor {
614            let tag = tag_of(&styled_dom, node);
615            display_list.items.push(DisplayListItem::HitTestArea {
616                bounds: WindowLogicalRect::new(rect.origin, rect.size),
617                tag: (tag, 0),
618            });
619        }
620
621        DomLayoutResult {
622            styled_dom,
623            layout_tree: LayoutTree {
624                nodes: Vec::new(),
625                warm: Vec::new(),
626                cold: Vec::new(),
627                root: 0,
628                dom_to_layout: BTreeMap::new(),
629                children_arena: Vec::new(),
630                children_offsets: Vec::new(),
631                subtree_needs_intrinsic: Vec::new(),
632            },
633            calculated_positions: Vec::new(),
634            viewport: LogicalRect::zero(),
635            display_list,
636            scroll_ids: HashMap::new(),
637            scroll_id_to_node_id: HashMap::new(),
638        }
639    }
640
641    /// Runs `f` with a callback environment over an empty `LayoutWindow` and no
642    /// hit node — the "nothing to anchor to" case.
643    fn with_env<R>(f: impl FnOnce(&Env<'_>) -> R) -> R {
644        with_env_cfg(None, f)
645    }
646
647    /// Runs `f` with a callback environment whose root DOM is `styled_dom`, whose
648    /// node `node` has the hit-test rect `rect`, and whose hit node is `node`.
649    fn with_anchored_env<R>(
650        styled_dom: StyledDom,
651        node: NodeId,
652        rect: LogicalRect,
653        f: impl FnOnce(&Env<'_>) -> R,
654    ) -> R {
655        with_env_cfg(Some((styled_dom, node, rect)), f)
656    }
657
658    fn with_env_cfg<R>(
659        anchored: Option<(StyledDom, NodeId, LogicalRect)>,
660        f: impl FnOnce(&Env<'_>) -> R,
661    ) -> R {
662        let mut layout_window =
663            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
664
665        let hit = match anchored {
666            Some((styled_dom, node, rect)) => {
667                layout_window
668                    .layout_results
669                    .insert(DomId::ROOT_ID, layout_result(styled_dom, Some((node, rect))));
670                DomNodeId {
671                    dom: DomId::ROOT_ID,
672                    node: NodeHierarchyItemId::from_crate_internal(Some(node)),
673                }
674            }
675            None => DomNodeId {
676                dom: DomId::ROOT_ID,
677                node: NodeHierarchyItemId::NONE,
678            },
679        };
680
681        let renderer_resources = RendererResources::default();
682        let previous_window_state: Option<FullWindowState> = None;
683        let current_window_state = FullWindowState::default();
684        let gl_context = OptionGlContextPtr::None;
685        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
686            BTreeMap::new();
687        let window_handle = RawWindowHandle::Unsupported;
688        let system_callbacks = ExternalSystemCallbacks::rust_internal();
689
690        let ref_data = CallbackInfoRefData {
691            layout_window: &layout_window,
692            renderer_resources: &renderer_resources,
693            previous_window_state: &previous_window_state,
694            current_window_state: &current_window_state,
695            gl_context: &gl_context,
696            current_scroll_manager: &scroll_states,
697            current_window_handle: &window_handle,
698            system_callbacks: &system_callbacks,
699            system_style: Arc::new(azul_css::system::SystemStyle::default()),
700            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
701            #[cfg(feature = "icu")]
702            icu_localizer: IcuLocalizerHandle::default(),
703            ctx: OptionRefAny::None,
704        };
705
706        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
707        let env = Env {
708            ref_data: &ref_data,
709            changes: &changes,
710            hit,
711        };
712        f(&env)
713    }
714
715    /// The labels of a queued `OpenMenu` change's items, in menu order.
716    fn menu_labels(menu: &Menu) -> Vec<String> {
717        menu.items
718            .as_ref()
719            .iter()
720            .map(|i| match i {
721                MenuItem::String(s) => s.label.as_str().to_string(),
722                other => panic!("the dropdown must only emit string items, got {other:?}"),
723            })
724            .collect()
725    }
726
727    // ==================================================================
728    // DropDown::new / Default  (constructor invariants)
729    // ==================================================================
730
731    #[test]
732    fn new_keeps_the_choices_and_starts_unselected_without_a_callback() {
733        let dd = DropDown::new(choices(&["a", "b", "c"]));
734
735        assert_eq!(dd.choices.len(), 3);
736        assert_eq!(
737            dd.choices.as_slice().iter().map(AzString::as_str).collect::<Vec<_>>(),
738            vec!["a", "b", "c"],
739            "choices must be stored verbatim, in order",
740        );
741        assert_eq!(dd.selected, 0, "a fresh dropdown selects the first item");
742        assert!(
743            dd.on_choice_change.as_ref().is_none(),
744            "`new` must not invent a callback",
745        );
746    }
747
748    #[test]
749    fn new_preserves_every_adversarial_choice_byte_for_byte() {
750        let originals = adversarial_choices();
751        let dd = DropDown::new(StringVec::from_vec(
752            originals.iter().cloned().map(AzString::from_string).collect(),
753        ));
754
755        assert_eq!(dd.choices.len(), originals.len(), "no choice may be dropped");
756        for (stored, original) in dd.choices.as_slice().iter().zip(&originals) {
757            assert_eq!(
758                stored.as_str(),
759                original.as_str(),
760                "a NUL / bidi / astral label must survive the StringVec round-trip",
761            );
762            assert_eq!(
763                stored.as_str().len(),
764                original.len(),
765                "byte length must be preserved — an embedded NUL must not truncate",
766            );
767        }
768    }
769
770    #[test]
771    fn new_on_an_empty_choice_list_still_reports_index_zero() {
772        // `selected == 0` points *past the end* of an empty list. That is the
773        // documented starting state, so every consumer (notably `dom()`) has to
774        // tolerate an out-of-range selection from the very first frame.
775        let dd = DropDown::new(StringVec::from_const_slice(&[]));
776        assert!(dd.choices.is_empty());
777        assert_eq!(dd.selected, 0);
778        assert!(dd.choices.as_slice().get(dd.selected).is_none());
779    }
780
781    #[test]
782    fn new_with_ten_thousand_choices_keeps_len_and_capacity_consistent() {
783        let n = 10_000;
784        let dd = DropDown::new(StringVec::from_vec(
785            (0..n).map(|i| AzString::from_string(i.to_string())).collect(),
786        ));
787
788        assert_eq!(dd.choices.len(), n);
789        assert!(
790            dd.choices.capacity() >= dd.choices.len(),
791            "capacity must never be smaller than len",
792        );
793        assert_eq!(dd.choices.as_slice().len(), n, "the C slice view must agree with len");
794        assert_eq!(dd.choices.as_slice()[n - 1].as_str(), (n - 1).to_string());
795    }
796
797    #[test]
798    fn default_is_the_empty_unselected_dropdown() {
799        let dd = DropDown::default();
800        assert!(dd.choices.is_empty());
801        assert_eq!(dd.selected, 0);
802        assert!(dd.on_choice_change.as_ref().is_none());
803        assert_eq!(dd, DropDown::new(StringVec::from_const_slice(&[])));
804    }
805
806    // ==================================================================
807    // set_on_choice_change / with_on_choice_change
808    // ==================================================================
809
810    #[test]
811    fn set_on_choice_change_stores_the_exact_refany_and_function_pointer() {
812        let l = log();
813        let data = RefAny::new(l);
814        let mut dd = DropDown::new(choices(&["a"]));
815        dd.set_on_choice_change(data.clone(), cb(record_choice));
816
817        let stored = dd
818            .on_choice_change
819            .as_ref()
820            .expect("the callback must be stored");
821        assert_eq!(
822            stored.refany, data,
823            "the widget must hold the caller's allocation, not a copy",
824        );
825        assert_eq!(
826            stored.callback.cb as usize, record_choice as usize,
827            "the function pointer must round-trip unchanged",
828        );
829        assert!(
830            stored.callback.ctx.as_ref().is_none(),
831            "a native Rust callback has no FFI context",
832        );
833    }
834
835    #[test]
836    fn set_on_choice_change_is_last_write_wins() {
837        let mut dd = DropDown::new(choices(&["a"]));
838        dd.set_on_choice_change(RefAny::new(log()), cb(record_choice));
839        let second = RefAny::new(log());
840        dd.set_on_choice_change(second.clone(), cb(reject_choice));
841
842        let stored = dd.on_choice_change.as_ref().expect("still exactly one callback");
843        assert_eq!(stored.callback.cb as usize, reject_choice as usize);
844        assert_eq!(stored.refany, second, "the second registration must replace the first");
845        assert_ne!(
846            record_choice as usize, reject_choice as usize,
847            "the two probes must not have been folded into one symbol",
848        );
849    }
850
851    #[test]
852    fn set_on_choice_change_does_not_disturb_the_choices_or_the_selection() {
853        let mut dd = adversarial_dropdown();
854        dd.selected = usize::MAX;
855        let before = dd.choices.clone();
856
857        dd.set_on_choice_change(RefAny::new(log()), cb(record_choice));
858
859        assert_eq!(dd.choices, before, "registering a callback must not touch the model");
860        assert_eq!(dd.selected, usize::MAX, "…nor the selection, however out of range");
861    }
862
863    #[test]
864    fn with_on_choice_change_is_the_setter_plus_a_move() {
865        let data = RefAny::new(log());
866        let built = DropDown::new(choices(&["a", "b"])).with_on_choice_change(data.clone(), cb(record_choice));
867
868        let mut expected = DropDown::new(choices(&["a", "b"]));
869        expected.set_on_choice_change(data, cb(record_choice));
870
871        assert_eq!(built, expected, "the builder must not differ from the setter");
872    }
873
874    #[test]
875    fn with_on_choice_change_preserves_an_out_of_range_selection() {
876        let mut dd = DropDown::new(choices(&["a"]));
877        dd.selected = usize::MAX;
878        let dd = dd.with_on_choice_change(RefAny::new(log()), cb(record_choice));
879
880        assert_eq!(dd.selected, usize::MAX, "the builder must not silently clamp");
881        assert_eq!(dd.choices.len(), 1);
882    }
883
884    #[test]
885    fn with_on_choice_change_accepts_a_zero_choice_dropdown() {
886        let dd = DropDown::default().with_on_choice_change(RefAny::new(log()), cb(record_choice));
887        assert!(dd.choices.is_empty());
888        assert!(
889            dd.on_choice_change.as_ref().is_some(),
890            "a callback on an empty dropdown is legal — it just can never fire",
891        );
892    }
893
894    // ==================================================================
895    // swap_with_default
896    // ==================================================================
897
898    #[test]
899    fn swap_with_default_moves_the_original_out_and_leaves_a_default() {
900        let data = RefAny::new(log());
901        let mut dd = DropDown::new(choices(&["a", "b"])).with_on_choice_change(data.clone(), cb(record_choice));
902        dd.selected = 1;
903
904        let taken = dd.swap_with_default();
905
906        assert_eq!(taken.choices.len(), 2);
907        assert_eq!(taken.selected, 1);
908        assert_eq!(
909            taken.on_choice_change.as_ref().expect("callback moved out").refany,
910            data,
911        );
912        assert_eq!(dd, DropDown::default(), "what stays behind must be the default");
913    }
914
915    #[test]
916    fn swap_with_default_is_idempotent_after_the_first_call() {
917        let mut dd = adversarial_dropdown();
918        let _first = dd.swap_with_default();
919        let second = dd.swap_with_default();
920
921        assert_eq!(second, DropDown::default(), "the second take yields a default");
922        assert_eq!(dd, DropDown::default(), "…and leaves another default behind");
923    }
924
925    #[test]
926    fn swap_with_default_preserves_an_out_of_range_selection_and_huge_labels() {
927        let mut dd = adversarial_dropdown();
928        dd.selected = usize::MAX;
929        let n = dd.choices.len();
930
931        let taken = dd.swap_with_default();
932
933        assert_eq!(taken.selected, usize::MAX, "swap must not normalise anything");
934        assert_eq!(taken.choices.len(), n);
935        assert_eq!(dd.selected, 0);
936        assert!(dd.choices.is_empty());
937    }
938
939    // ==================================================================
940    // DropDown::dom
941    // ==================================================================
942
943    #[test]
944    fn dom_labels_the_selected_choice() {
945        for (idx, expected) in [(0, "alpha"), (1, "beta"), (2, "gamma")] {
946            let mut dd = DropDown::new(choices(&["alpha", "beta", "gamma"]));
947            dd.selected = idx;
948            let dom = dd.dom();
949            assert_eq!(label_of(&dom), expected, "index {idx} must label the trigger");
950        }
951    }
952
953    #[test]
954    fn dom_falls_back_to_an_empty_label_for_an_out_of_range_selection() {
955        // len, len+1 and the arithmetic limit: `.get()` returns None for all of
956        // them, and the documented fallback is the empty string — never a panic
957        // and never a stale neighbour.
958        for idx in [3_usize, 4, usize::MAX - 1, usize::MAX] {
959            let mut dd = DropDown::new(choices(&["a", "b", "c"]));
960            dd.selected = idx;
961            let dom = dd.dom();
962            assert_eq!(label_of(&dom), "", "selected = {idx} must render an empty label");
963        }
964    }
965
966    #[test]
967    fn dom_on_an_empty_dropdown_renders_an_empty_label() {
968        let dom = DropDown::default().dom();
969        assert_eq!(label_of(&dom), "");
970        assert_eq!(dom.children.len(), 2, "label + arrow are rendered regardless");
971    }
972
973    #[test]
974    fn dom_label_survives_unicode_embedded_nuls_and_huge_strings() {
975        let originals = adversarial_choices();
976        for (idx, original) in originals.iter().enumerate() {
977            let mut dd = DropDown::new(StringVec::from_vec(
978                originals.iter().cloned().map(AzString::from_string).collect(),
979            ));
980            dd.selected = idx;
981            let dom = dd.dom();
982            assert_eq!(
983                label_of(&dom),
984                original.as_str(),
985                "label {idx} must reach the text node byte-for-byte",
986            );
987        }
988    }
989
990    #[test]
991    fn dom_shape_is_a_trigger_with_a_wrapped_label_and_an_arrow_icon() {
992        let dom = DropDown::new(choices(&["a"])).dom();
993
994        assert!(matches!(dom.root.get_node_type(), NodeType::Div), "the trigger is a div");
995        assert_eq!(dom.children.len(), 2, "exactly a label and an arrow");
996
997        let kids = dom.children.as_ref();
998        assert!(matches!(kids[0].root.get_node_type(), NodeType::P), "the label is block-formatted");
999        assert_eq!(kids[0].children.len(), 1, "the <p> wraps exactly one text node");
1000
1001        match kids[1].root.get_node_type() {
1002            NodeType::Icon(s) => assert_eq!(s.as_ref().as_str(), "arrow_drop_down"),
1003            other => panic!("expected the arrow icon, got {other:?}"),
1004        }
1005        assert!(kids[1].children.is_empty(), "the icon is a leaf");
1006    }
1007
1008    #[test]
1009    fn dom_child_count_cache_is_honest_for_every_selection() {
1010        for idx in [0_usize, 1, 2, 99, usize::MAX] {
1011            let mut dd = DropDown::new(choices(&["a", "b"]));
1012            dd.selected = idx;
1013            let dom = dd.dom();
1014            assert_eq!(
1015                dom.estimated_total_children,
1016                count_descendants(&dom),
1017                "selected = {idx}: a stale cache makes the compact-DOM arena under-allocate",
1018            );
1019            assert_eq!(dom.estimated_total_children, 3, "p + text + icon");
1020        }
1021    }
1022
1023    #[test]
1024    fn dom_marks_the_trigger_focusable_and_gives_it_the_widget_class() {
1025        let dom = DropDown::new(choices(&["a"])).dom();
1026
1027        assert_eq!(
1028            dom.root.get_tab_index(),
1029            Some(TabIndex::Auto),
1030            "the popup opens on focus, so the trigger must be reachable by keyboard",
1031        );
1032        assert_eq!(classes(&dom), vec!["__azul-native-dropdown".to_string()]);
1033    }
1034
1035    #[test]
1036    fn dom_registers_exactly_one_focus_received_callback() {
1037        let dom = DropDown::new(choices(&["a", "b"])).dom();
1038        let cbs = dom.root.callbacks.as_ref();
1039
1040        assert_eq!(cbs.len(), 1, "one handler — a duplicate would open two popups");
1041        assert_eq!(cbs[0].event, EventFilter::Focus(FocusEventFilter::FocusReceived));
1042        assert_eq!(cbs[0].callback.cb, on_dropdown_click as usize);
1043        assert!(cbs[0].callback.ctx.as_ref().is_none());
1044    }
1045
1046    #[test]
1047    fn dom_hands_the_whole_widget_to_the_callback_refany() {
1048        let mut dd = adversarial_dropdown();
1049        dd.selected = 4;
1050        let expected: Vec<String> = dd.choices.as_slice().iter().map(|c| c.as_str().to_string()).collect();
1051
1052        let (_dom, mut refany) = rendered(dd);
1053        let stored = refany
1054            .downcast_ref::<DropDown>()
1055            .expect("dom() must store the DropDown itself, unwrapped");
1056
1057        assert_eq!(stored.selected, 4);
1058        assert_eq!(
1059            stored.choices.as_slice().iter().map(|c| c.as_str().to_string()).collect::<Vec<_>>(),
1060            expected,
1061            "the handler must see the same choices the label was built from",
1062        );
1063    }
1064
1065    #[test]
1066    fn from_dropdown_for_dom_renders_the_same_trigger_as_dom() {
1067        let mut dd = DropDown::new(choices(&["a", "b", "c"]));
1068        dd.selected = 2;
1069
1070        let direct = dd.clone().dom();
1071        let converted = Dom::from(dd);
1072
1073        assert_eq!(label_of(&direct), label_of(&converted));
1074        assert_eq!(direct.children.len(), converted.children.len());
1075        assert_eq!(classes(&direct), classes(&converted));
1076        assert_eq!(direct.estimated_total_children, converted.estimated_total_children);
1077    }
1078
1079    #[test]
1080    fn dom_with_ten_thousand_choices_renders_only_the_selected_one() {
1081        let n = 10_000;
1082        let mut dd = DropDown::new(StringVec::from_vec(
1083            (0..n).map(|i| AzString::from_string(i.to_string())).collect(),
1084        ));
1085        dd.selected = n - 1;
1086
1087        let dom = dd.dom();
1088        assert_eq!(label_of(&dom), (n - 1).to_string());
1089        assert_eq!(
1090            dom.estimated_total_children, 3,
1091            "the trigger must not materialise one node per choice",
1092        );
1093    }
1094
1095    #[test]
1096    fn dom_does_not_mutate_the_selection_it_was_given() {
1097        // The widget is stateless w.r.t. selection: `dom()` reads `selected` and
1098        // never writes it. Anything that changes the label has to go through the
1099        // caller's own state, updated from the choice-change callback.
1100        let mut dd = DropDown::new(choices(&["a", "b"]));
1101        dd.selected = 1;
1102        let (_dom, mut refany) = rendered(dd);
1103        assert_eq!(refany.downcast_ref::<DropDown>().expect("stored widget").selected, 1);
1104    }
1105
1106    // ==================================================================
1107    // on_dropdown_click
1108    // ==================================================================
1109
1110    #[test]
1111    fn on_dropdown_click_ignores_a_refany_of_the_wrong_type() {
1112        with_env(|env| {
1113            let update = on_dropdown_click(RefAny::new(0_usize), env.info());
1114            assert_eq!(update, Update::DoNothing);
1115            assert!(
1116                env.take_changes().is_empty(),
1117                "a type mismatch must be a silent no-op, not a half-built menu",
1118            );
1119        });
1120    }
1121
1122    #[test]
1123    fn on_dropdown_click_without_a_hit_node_opens_nothing() {
1124        let (_dom, refany) = rendered(DropDown::new(choices(&["a", "b"])));
1125        with_env(|env| {
1126            // The hit node is NONE and the window has no layout results, so the
1127            // popup has nothing to anchor to.
1128            let update = on_dropdown_click(refany.clone(), env.info());
1129            assert_eq!(update, Update::DoNothing);
1130            assert!(
1131                env.take_changes().is_empty(),
1132                "a failed anchor must not queue a half-open menu",
1133            );
1134        });
1135    }
1136
1137    #[test]
1138    fn on_dropdown_click_opens_one_menu_item_per_choice_in_order() {
1139        let labels = ["a", "", "\u{5E9}\u{5DC}\u{5D5}\u{5DD}", "a\0b"];
1140        let (dom, refany) = rendered(DropDown::new(choices(&labels)));
1141        let styled_dom = StyledDom::create_from_dom(dom);
1142        let rect = LogicalRect::new(LogicalPosition::new(10.0, 20.0), LogicalSize::new(100.0, 30.0));
1143
1144        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1145            let update = on_dropdown_click(refany.clone(), env.info());
1146            assert_eq!(update, Update::DoNothing, "opening the popup is not a re-layout");
1147
1148            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
1149                panic!("expected exactly one OpenMenu change");
1150            };
1151            assert_eq!(
1152                menu_labels(&menu),
1153                labels.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(),
1154                "menu order must mirror choice order, NULs and RTL included",
1155            );
1156        });
1157    }
1158
1159    #[test]
1160    fn on_dropdown_click_on_an_empty_dropdown_opens_an_empty_menu() {
1161        let (dom, refany) = rendered(DropDown::default());
1162        let styled_dom = StyledDom::create_from_dom(dom);
1163        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(1.0, 1.0));
1164
1165        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1166            assert_eq!(on_dropdown_click(refany.clone(), env.info()), Update::DoNothing);
1167            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
1168                panic!("expected an OpenMenu change");
1169            };
1170            assert!(menu.items.is_empty(), "no choices means no items — not a panic");
1171        });
1172    }
1173
1174    #[test]
1175    fn on_dropdown_click_anchors_the_menu_below_the_trigger() {
1176        let (dom, refany) = rendered(DropDown::new(choices(&["a"])));
1177        let styled_dom = StyledDom::create_from_dom(dom);
1178        let rect = LogicalRect::new(LogicalPosition::new(10.0, 20.0), LogicalSize::new(100.0, 30.0));
1179
1180        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1181            on_dropdown_click(refany.clone(), env.info());
1182            let CallbackChange::OpenMenu { menu, position } = env.take_one() else {
1183                panic!("expected an OpenMenu change");
1184            };
1185            let p = position.expect("the popup must be pinned to the trigger");
1186            assert_eq!((p.x, p.y), (10.0, 50.0), "bottom-left of the trigger rect");
1187            assert!(matches!(menu.position, MenuPopupPosition::BottomOfHitRect));
1188            assert!(matches!(menu.context_mouse_btn, ContextMenuMouseButton::Right));
1189        });
1190    }
1191
1192    #[test]
1193    fn on_dropdown_click_is_repeatable_and_does_not_consume_the_widget() {
1194        let (dom, refany) = rendered(DropDown::new(choices(&["a", "b"])));
1195        let styled_dom = StyledDom::create_from_dom(dom);
1196        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
1197
1198        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1199            for round in 0..3 {
1200                on_dropdown_click(refany.clone(), env.info());
1201                let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
1202                    panic!("round {round}: expected an OpenMenu change");
1203                };
1204                assert_eq!(menu_labels(&menu), vec!["a".to_string(), "b".to_string()]);
1205            }
1206        });
1207    }
1208
1209    #[test]
1210    fn on_dropdown_click_tags_every_item_with_its_own_index_and_handler() {
1211        let l = log();
1212        let dd = DropDown::new(choices(&["a", "b", "c"]))
1213            .with_on_choice_change(RefAny::new(l), cb(record_choice));
1214        let (dom, refany) = rendered(dd);
1215        let styled_dom = StyledDom::create_from_dom(dom);
1216        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
1217
1218        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1219            on_dropdown_click(refany.clone(), env.info());
1220            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
1221                panic!("expected an OpenMenu change");
1222            };
1223
1224            for (idx, item) in menu.items.as_ref().iter().enumerate() {
1225                let MenuItem::String(s) = item else {
1226                    panic!("item {idx} is not a string item");
1227                };
1228                let menu_cb = s.callback.as_ref().expect("every item must be clickable");
1229                assert_eq!(
1230                    menu_cb.callback.cb, on_choice_selected as usize,
1231                    "item {idx} must route through the widget's own handler",
1232                );
1233                let mut data = menu_cb.refany.clone();
1234                let payload = data
1235                    .downcast_ref::<ChoiceCallbackData>()
1236                    .expect("the item payload must be a ChoiceCallbackData");
1237                assert_eq!(payload.choice_id, idx, "item {idx} carries the wrong index");
1238                assert!(
1239                    payload.on_choice_change.as_ref().is_some(),
1240                    "item {idx} lost the user callback on the way into the menu",
1241                );
1242            }
1243        });
1244    }
1245
1246    // ==================================================================
1247    // on_choice_selected
1248    // ==================================================================
1249
1250    #[test]
1251    fn on_choice_selected_ignores_a_refany_of_the_wrong_type() {
1252        with_env(|env| {
1253            let update = on_choice_selected(RefAny::new(0_usize), env.info());
1254            assert_eq!(update, Update::DoNothing);
1255            assert!(env.take_changes().is_empty());
1256        });
1257    }
1258
1259    #[test]
1260    fn on_choice_selected_without_a_registered_callback_does_nothing() {
1261        let data = RefAny::new(ChoiceCallbackData {
1262            choice_id: 7,
1263            on_choice_change: None.into(),
1264        });
1265        with_env(|env| {
1266            let update = on_choice_selected(data.clone(), env.info());
1267            assert_eq!(update, Update::DoNothing, "an unwired dropdown must stay silent");
1268            assert!(env.take_changes().is_empty());
1269        });
1270    }
1271
1272    #[test]
1273    fn on_choice_selected_forwards_the_index_and_propagates_the_return_value() {
1274        let l = log();
1275        let data = RefAny::new(ChoiceCallbackData {
1276            choice_id: 2,
1277            on_choice_change: Some(DropDownOnChoiceChange {
1278                refany: RefAny::new(l.clone()),
1279                callback: cb(record_choice),
1280            })
1281            .into(),
1282        });
1283
1284        with_env(|env| {
1285            let update = on_choice_selected(data.clone(), env.info());
1286            assert_eq!(update, Update::RefreshDom, "the user's Update must not be swallowed");
1287        });
1288        assert_eq!(entries(&l), vec![2], "the callback must see its own index");
1289    }
1290
1291    #[test]
1292    fn on_choice_selected_forwards_usize_max_unchanged() {
1293        // `choice_id` is a plain index with no upper bound: the limit value must
1294        // pass through untouched rather than wrap, saturate or panic.
1295        let l = log();
1296        let data = RefAny::new(ChoiceCallbackData {
1297            choice_id: usize::MAX,
1298            on_choice_change: Some(DropDownOnChoiceChange {
1299                refany: RefAny::new(l.clone()),
1300                callback: cb(record_choice),
1301            })
1302            .into(),
1303        });
1304
1305        with_env(|env| {
1306            assert_eq!(on_choice_selected(data.clone(), env.info()), Update::RefreshDom);
1307        });
1308        assert_eq!(entries(&l), vec![usize::MAX]);
1309    }
1310
1311    #[test]
1312    fn on_choice_selected_is_repeatable_on_the_same_payload() {
1313        let l = log();
1314        let data = RefAny::new(ChoiceCallbackData {
1315            choice_id: 1,
1316            on_choice_change: Some(DropDownOnChoiceChange {
1317                refany: RefAny::new(l.clone()),
1318                callback: cb(record_choice),
1319            })
1320            .into(),
1321        });
1322
1323        with_env(|env| {
1324            // The handler takes an *exclusive* borrow of the payload; if it were
1325            // ever leaked, the second call would fail to downcast and silently
1326            // return DoNothing.
1327            for _ in 0..3 {
1328                assert_eq!(on_choice_selected(data.clone(), env.info()), Update::RefreshDom);
1329            }
1330        });
1331        assert_eq!(entries(&l), vec![1, 1, 1], "the borrow must be released each time");
1332    }
1333
1334    #[test]
1335    fn on_choice_selected_uses_the_callback_that_was_registered_last() {
1336        let l = log();
1337        let mut dd = DropDown::new(choices(&["a", "b"]));
1338        dd.set_on_choice_change(RefAny::new(l.clone()), cb(record_choice));
1339        dd.set_on_choice_change(RefAny::new(l.clone()), cb(reject_choice));
1340
1341        let data = RefAny::new(ChoiceCallbackData {
1342            choice_id: 1,
1343            on_choice_change: dd.on_choice_change.clone(),
1344        });
1345
1346        with_env(|env| {
1347            assert_eq!(on_choice_selected(data.clone(), env.info()), Update::RefreshDomAllWindows);
1348        });
1349        assert_eq!(entries(&l), vec![1 + SENTINEL], "the replaced callback must not fire");
1350    }
1351
1352    // ==================================================================
1353    // End-to-end: focus -> popup -> pick an item
1354    // ==================================================================
1355
1356    #[test]
1357    fn picking_a_menu_item_delivers_exactly_that_index_to_the_user_callback() {
1358        let l = log();
1359        let dd = DropDown::new(choices(&["a", "b", "c", "d"]))
1360            .with_on_choice_change(RefAny::new(l.clone()), cb(record_choice));
1361        let (dom, refany) = rendered(dd);
1362        let styled_dom = StyledDom::create_from_dom(dom);
1363        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
1364
1365        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1366            on_dropdown_click(refany.clone(), env.info());
1367            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
1368                panic!("expected an OpenMenu change");
1369            };
1370
1371            // Deliberately out of order: the index must come from the item, not
1372            // from the order in which items happen to be clicked.
1373            for idx in [3_usize, 0, 2, 1] {
1374                let MenuItem::String(s) = &menu.items.as_ref()[idx] else {
1375                    panic!("item {idx} is not a string item");
1376                };
1377                let payload = s.callback.as_ref().expect("clickable").refany.clone();
1378                assert_eq!(
1379                    on_choice_selected(payload, env.info()),
1380                    Update::RefreshDom,
1381                    "item {idx} must reach the user callback",
1382                );
1383            }
1384        });
1385
1386        assert_eq!(entries(&l), vec![3, 0, 2, 1]);
1387    }
1388
1389    #[test]
1390    fn picking_an_item_does_not_move_the_widgets_own_selection() {
1391        // NOTE (documented behaviour, not an accident): `DropDown` never updates
1392        // its own `selected` field. Selection state lives with the caller, which
1393        // is why the trigger label only changes once the caller re-renders. If
1394        // this ever starts self-updating, this assertion is the tripwire.
1395        let l = log();
1396        let dd = DropDown::new(choices(&["a", "b"]))
1397            .with_on_choice_change(RefAny::new(l.clone()), cb(record_choice));
1398        let (dom, mut refany) = rendered(dd);
1399        let styled_dom = StyledDom::create_from_dom(dom);
1400        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
1401
1402        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
1403            on_dropdown_click(refany.clone(), env.info());
1404            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
1405                panic!("expected an OpenMenu change");
1406            };
1407            let MenuItem::String(s) = &menu.items.as_ref()[1] else {
1408                panic!("item 1 is not a string item");
1409            };
1410            let payload = s.callback.as_ref().expect("clickable").refany.clone();
1411            on_choice_selected(payload, env.info());
1412        });
1413
1414        assert_eq!(entries(&l), vec![1], "the pick was delivered");
1415        assert_eq!(
1416            refany.downcast_ref::<DropDown>().expect("stored widget").selected,
1417            0,
1418            "the widget's own `selected` stays where the caller put it",
1419        );
1420    }
1421}