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

            
7
use 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
18
use 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

            
33
use 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.
40
pub type DropDownOnChoiceChangeCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
41
impl_widget_callback!(
42
    DropDownOnChoiceChange,
43
    OptionDropDownOnChoiceChange,
44
    DropDownOnChoiceChangeCallback,
45
    DropDownOnChoiceChangeCallbackType
46
);
47

            
48
azul_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

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

            
68
// -- Layout constants --
69

            
70
const FONT_SIZE_PX: isize = 13;
71
const ARROW_FONT_SIZE_PX: isize = 18;
72
const PADDING_HORIZONTAL_PX: isize = 4;
73
const PADDING_VERTICAL_PX: isize = 2;
74
const LABEL_PADDING_RIGHT_PX: isize = 8;
75
const BORDER_WIDTH_PX: isize = 1;
76

            
77
// -- Colors --
78

            
79
const BORDER_NORMAL: ColorU = ColorU { r: 172, g: 172, b: 172, a: 255 };
80
const BORDER_HOVER: ColorU = ColorU { r: 126, g: 180, b: 234, a: 255 };
81
const BORDER_FOCUS: ColorU = ColorU { r: 86, g: 157, b: 229, a: 255 };
82

            
83
const BG_GRADIENT_TOP: ColorU = ColorU { r: 245, g: 245, b: 245, a: 255 };
84
const BG_GRADIENT_BOTTOM: ColorU = ColorU { r: 235, g: 235, b: 235, a: 255 };
85
const BG_HOVER_TOP: ColorU = ColorU { r: 234, g: 244, b: 252, a: 255 };
86
const BG_HOVER_BOTTOM: ColorU = ColorU { r: 218, g: 236, b: 252, a: 255 };
87
const BG_ACTIVE_TOP: ColorU = ColorU { r: 218, g: 236, b: 252, a: 255 };
88
const BG_ACTIVE_BOTTOM: ColorU = ColorU { r: 202, g: 226, b: 248, a: 255 };
89

            
90
const 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

            
109
const 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

            
128
const 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

            
149
static 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

            
202
static 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

            
209
static 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)]
222
pub 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
    /// Style of the clickable trigger wrapper.
230
    pub wrapper_style: CssPropertyWithConditionsVec,
231
    /// Style of the selected-choice label.
232
    pub label_style: CssPropertyWithConditionsVec,
233
    /// Style of the drop-down arrow icon.
234
    pub arrow_style: CssPropertyWithConditionsVec,
235
}
236

            
237
impl Default for DropDown {
238
68
    fn default() -> Self {
239
68
        Self {
240
68
            choices: StringVec::from_const_slice(&[]),
241
68
            selected: 0,
242
68
            on_choice_change: None.into(),
243
68
            wrapper_style: CssPropertyWithConditionsVec::from_const_slice(DROPDOWN_WRAPPER_STYLE),
244
68
            label_style: CssPropertyWithConditionsVec::from_const_slice(DROPDOWN_LABEL_STYLE),
245
68
            arrow_style: CssPropertyWithConditionsVec::from_const_slice(
246
68
                DROPDOWN_ARROW_ICON_STYLE,
247
68
            ),
248
68
        }
249
68
    }
250
}
251

            
252
impl DropDown {
253
    /// Creates a new `DropDown` with the given choices and no callback.
254
57
    #[must_use] pub fn new(choices: StringVec) -> Self {
255
57
        Self {
256
57
            choices,
257
57
            ..Self::default()
258
57
        }
259
57
    }
260

            
261
    /// Sets the callback invoked when the user selects a different choice.
262
14
    pub fn set_on_choice_change<C: Into<DropDownOnChoiceChangeCallback>>(&mut self, data: RefAny, callback: C) {
263
14
        self.on_choice_change = Some(DropDownOnChoiceChange {
264
14
            callback: callback.into(),
265
14
            refany: data,
266
14
        }).into();
267
14
    }
268

            
269
    /// Builder variant of [`Self::set_on_choice_change`].
270
    #[must_use]
271
7
    pub fn with_on_choice_change<C: Into<DropDownOnChoiceChangeCallback>>(mut self, data: RefAny, callback: C) -> Self {
272
7
        self.set_on_choice_change(data, callback);
273
7
        self
274
7
    }
275

            
276
    /// Replaces `self` with the default value and returns the original.
277
    #[must_use]
278
4
    pub fn swap_with_default(&mut self) -> Self {
279
4
        let mut m = Self::default();
280
4
        core::mem::swap(&mut m, self);
281
4
        m
282
4
    }
283

            
284
    /// Builds the DOM tree for this drop-down widget.
285
45
    #[must_use] pub fn dom(self) -> Dom {
286
        const DROPDOWN_CLASS: &[IdOrClass] =
287
            &[Class(AzString::from_const_str("__azul-native-dropdown"))];
288

            
289
45
        let selected_text = self.choices
290
45
            .as_slice()
291
45
            .get(self.selected)
292
45
            .cloned()
293
45
            .unwrap_or_else(|| AzString::from_const_str(""));
294

            
295
        // The full widget state travels into the focus callback; the style
296
        // vecs are pulled out first so the rendered nodes use them directly.
297
45
        let wrapper_style = self.wrapper_style.clone();
298
45
        let label_style = self.label_style.clone();
299
45
        let arrow_style = self.arrow_style.clone();
300
45
        let refany = RefAny::new(self);
301

            
302
        // Wrapper: focusable trigger that opens popup on focus
303

            
304

            
305
45
        Dom::create_div()
306
45
            .with_css_props(wrapper_style)
307
45
            .with_ids_and_classes(IdOrClassVec::from_const_slice(DROPDOWN_CLASS))
308
45
            .with_tab_index(TabIndex::Auto)
309
45
            .with_callbacks(
310
45
                vec![CoreCallbackData {
311
45
                    event: EventFilter::Focus(FocusEventFilter::FocusReceived),
312
45
                    refany,
313
45
                    callback: CoreCallback {
314
45
                        cb: on_dropdown_click as usize,
315
45
                        ctx: azul_core::refany::OptionRefAny::None,
316
45
                    },
317
45
                }]
318
45
                .into(),
319
            )
320
45
            .with_children(DomVec::from_vec(vec![
321
                // Selected text label wrapped in <p> for proper block formatting
322
45
                Dom::create_p()
323
45
                    .with_css_props(label_style)
324
45
                    .with_children(DomVec::from_vec(vec![
325
45
                        Dom::create_text_do_not_use_without_block_level_wrapper(selected_text),
326
                    ])),
327
                // Arrow icon (resolved via Material Icons)
328
45
                Dom::create_icon(AzString::from_const_str("arrow_drop_down"))
329
45
                    .with_css_props(arrow_style),
330
            ]))
331
45
    }
332
}
333

            
334
// ============================================================================
335
// Internal callback data types
336
// ============================================================================
337

            
338
struct ChoiceCallbackData {
339
    choice_id: usize,
340
    on_choice_change: OptionDropDownOnChoiceChange,
341
}
342

            
343
// ============================================================================
344
// Callbacks
345
// ============================================================================
346

            
347
11
extern "C" fn on_dropdown_click(mut refany: RefAny, mut info: CallbackInfo) -> Update {
348
11
    let Some(refany) = refany.downcast_ref::<DropDown>() else {
349
1
        return Update::DoNothing;
350
    };
351

            
352
10
    let menu_items: Vec<MenuItem> = refany
353
10
        .choices
354
10
        .iter()
355
10
        .enumerate()
356
22
        .map(|(idx, choice)| {
357
22
            MenuItem::String(StringMenuItem::create(choice.clone()).with_callback(
358
22
                RefAny::new(ChoiceCallbackData {
359
22
                    choice_id: idx,
360
22
                    on_choice_change: refany.on_choice_change.clone(),
361
22
                }),
362
22
                on_choice_selected as usize,
363
22
            ))
364
22
        })
365
10
        .collect();
366

            
367
10
    let menu = Menu {
368
10
        items: menu_items.into(),
369
10
        position: MenuPopupPosition::BottomOfHitRect,
370
10
        context_mouse_btn: ContextMenuMouseButton::Right,
371
10
    };
372

            
373
10
    info.open_menu_for_hit_node(menu);
374
10
    Update::DoNothing
375
11
}
376

            
377
13
extern "C" fn on_choice_selected(mut refany: RefAny, info: CallbackInfo) -> Update {
378
13
    let Some(mut refany) = refany.downcast_mut::<ChoiceCallbackData>() else {
379
1
        return Update::DoNothing;
380
    };
381

            
382
12
    let choice_id = refany.choice_id;
383

            
384
12
    match refany.on_choice_change.as_mut() {
385
11
        Some(DropDownOnChoiceChange { refany, callback }) => {
386
11
            (callback.cb)(refany.clone(), info, choice_id)
387
        }
388
1
        None => Update::DoNothing,
389
    }
390
13
}
391

            
392
impl From<DropDown> for Dom {
393
1
    fn from(b: DropDown) -> Self {
394
1
        b.dom()
395
1
    }
396
}
397

            
398
#[cfg(test)]
399
mod autotest_generated {
400
    use std::{
401
        collections::{BTreeMap, HashMap},
402
        sync::{Arc, Mutex},
403
    };
404

            
405
    use azul_core::{
406
        dom::{DomId, DomNodeId, NodeId, NodeType},
407
        geom::{LogicalPosition, LogicalRect, LogicalSize, OptionLogicalPosition},
408
        gl::OptionGlContextPtr,
409
        hit_test::ScrollPosition,
410
        refany::OptionRefAny,
411
        resources::RendererResources,
412
        styled_dom::{NodeHierarchyItemId, StyledDom},
413
        window::{MonitorVec, RawWindowHandle},
414
    };
415
    use rust_fontconfig::FcFontCache;
416

            
417
    use super::*;
418
    #[cfg(feature = "icu")]
419
    use crate::icu::IcuLocalizerHandle;
420
    use crate::{
421
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
422
        solver3::{
423
            display_list::{DisplayList, DisplayListItem, WindowLogicalRect},
424
            layout_tree::LayoutTree,
425
        },
426
        window::{DomLayoutResult, LayoutWindow},
427
        window_state::FullWindowState,
428
    };
429

            
430
    // ------------------------------------------------------------------
431
    // Fixtures
432
    // ------------------------------------------------------------------
433

            
434
    /// Where every user callback records the index it was handed. Passed through
435
    /// the widget as a `RefAny`, so the assertions exercise the real data plumbing
436
    /// (`RefAny::new` -> clone -> `downcast_ref`) rather than a side channel.
437
    type ChoiceLog = Arc<Mutex<Vec<usize>>>;
438

            
439
    /// Offset added by `reject_choice` so the two recorders below stay
440
    /// distinguishable in the log.
441
    const SENTINEL: usize = 1_000_000;
442

            
443
    extern "C" fn record_choice(mut data: RefAny, _info: CallbackInfo, choice_index: usize) -> Update {
444
        if let Some(log) = data.downcast_ref::<ChoiceLog>() {
445
            log.lock().expect("choice log poisoned").push(choice_index);
446
        }
447
        Update::RefreshDom
448
    }
449

            
450
    /// A second callback with a *deliberately different body*: two identical
451
    /// `extern "C"` bodies are legal prey for identical-code folding, which would
452
    /// merge their addresses and make the "last write wins" assertion vacuous.
453
    extern "C" fn reject_choice(mut data: RefAny, _info: CallbackInfo, choice_index: usize) -> Update {
454
        if let Some(log) = data.downcast_ref::<ChoiceLog>() {
455
            log.lock()
456
                .expect("choice log poisoned")
457
                .push(choice_index.wrapping_add(SENTINEL));
458
        }
459
        Update::RefreshDomAllWindows
460
    }
461

            
462
    fn log() -> ChoiceLog {
463
        Arc::new(Mutex::new(Vec::new()))
464
    }
465

            
466
    fn entries(log: &ChoiceLog) -> Vec<usize> {
467
        log.lock().expect("choice log poisoned").clone()
468
    }
469

            
470
    fn cb(f: DropDownOnChoiceChangeCallbackType) -> DropDownOnChoiceChangeCallback {
471
        DropDownOnChoiceChangeCallback::from(f)
472
    }
473

            
474
    fn choices(items: &[&str]) -> StringVec {
475
        StringVec::from_vec(
476
            items
477
                .iter()
478
                .map(|s| AzString::from_string((*s).to_string()))
479
                .collect(),
480
        )
481
    }
482

            
483
    /// Adversarial choice labels: empty, whitespace, combining marks, ZWJ emoji,
484
    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
485
    /// truncate), bidi overrides, control characters, and strings that collide
486
    /// with the widget's own class / icon names.
487
    fn adversarial_choices() -> Vec<String> {
488
        let mut v: Vec<String> = [
489
            "",
490
            " ",
491
            "OK",
492
            "e\u{0301}",                                   // e + combining acute
493
            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
494
            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
495
            "\0",                                          // a lone NUL
496
            "a\0b",                                        // embedded NUL
497
            "\u{FFFD}\u{202E}\u{200B}",                    // replacement, RTL override, ZWSP
498
            "…\t\r\n",                                     // control chars in a label
499
            "__azul-native-dropdown",                      // looks like the widget's own class
500
            "arrow_drop_down",                             // looks like the widget's own icon
501
        ]
502
        .iter()
503
        .map(|s| (*s).to_string())
504
        .collect();
505
        v.push("x".repeat(100_000));
506
        v
507
    }
508

            
509
    fn adversarial_dropdown() -> DropDown {
510
        DropDown::new(StringVec::from_vec(
511
            adversarial_choices().into_iter().map(AzString::from_string).collect(),
512
        ))
513
    }
514

            
515
    // ------------------------------------------------------------------
516
    // DOM probes
517
    // ------------------------------------------------------------------
518

            
519
    /// The text the trigger displays: `root > p > text`. Panics loudly (rather
520
    /// than returning `None`) if the shape ever changes, because every label
521
    /// assertion below silently depends on that shape.
522
    fn label_of(dom: &Dom) -> &str {
523
        let p = dom
524
            .children
525
            .as_ref()
526
            .first()
527
            .expect("the trigger must have a label child");
528
        let text = p
529
            .children
530
            .as_ref()
531
            .first()
532
            .expect("the label must wrap a text node");
533
        match text.root.get_node_type() {
534
            NodeType::Text(s) => s.as_ref().as_str(),
535
            other => panic!("expected a text node, got {other:?}"),
536
        }
537
    }
538

            
539
    fn classes(dom: &Dom) -> Vec<String> {
540
        dom.root
541
            .get_ids_and_classes()
542
            .as_ref()
543
            .iter()
544
            .filter_map(|c| match c {
545
                Class(s) => Some(s.as_str().to_string()),
546
                IdOrClass::Id(_) => None,
547
            })
548
            .collect()
549
    }
550

            
551
    /// The recursive descendant count. `Dom::estimated_total_children` is a
552
    /// *cached* value that, if too small, makes `convert_dom_into_compact_dom`
553
    /// under-allocate its arenas and panic on out-of-bounds writes.
554
    fn count_descendants(dom: &Dom) -> usize {
555
        dom.children
556
            .as_ref()
557
            .iter()
558
            .map(|c| 1 + count_descendants(c))
559
            .sum()
560
    }
561

            
562
    /// Renders `dd`, then hands back both the DOM *and* the very `RefAny` the
563
    /// widget registered on its own focus callback. Driving `on_dropdown_click`
564
    /// with that `RefAny` is the real wiring - nothing is re-created by hand, so
565
    /// a mismatch between what `dom()` stores and what the handler expects
566
    /// cannot hide behind the fixture.
567
    fn rendered(dd: DropDown) -> (Dom, RefAny) {
568
        let dom = dd.dom();
569
        let refany = dom.root.callbacks.as_ref()[0].refany.clone();
570
        (dom, refany)
571
    }
572

            
573
    // ------------------------------------------------------------------
574
    // CallbackInfo harness (mirrors the one in `check_box.rs` / `timer.rs`)
575
    // ------------------------------------------------------------------
576

            
577
    struct Env<'a> {
578
        ref_data: &'a CallbackInfoRefData<'a>,
579
        changes: &'a Arc<Mutex<Vec<CallbackChange>>>,
580
        hit: DomNodeId,
581
    }
582

            
583
    impl Env<'_> {
584
        fn info(&self) -> CallbackInfo {
585
            CallbackInfo::new(
586
                self.ref_data,
587
                self.changes,
588
                self.hit,
589
                OptionLogicalPosition::None,
590
                OptionLogicalPosition::None,
591
            )
592
        }
593

            
594
        fn take_changes(&self) -> Vec<CallbackChange> {
595
            self.changes
596
                .lock()
597
                .map(|mut c| core::mem::take(&mut *c))
598
                .unwrap_or_default()
599
        }
600

            
601
        fn take_one(&self) -> CallbackChange {
602
            let mut changes = self.take_changes();
603
            assert_eq!(changes.len(), 1, "expected exactly one change: {changes:?}");
604
            changes.remove(0)
605
        }
606
    }
607

            
608
    /// The tag the hit-tester would use for `node`. `open_menu_for_node` resolves
609
    /// the anchor rect through this mapping, so a forged hit-test area must reuse
610
    /// the id the styling pass actually assigned.
611
    fn tag_of(styled_dom: &StyledDom, node: NodeId) -> u64 {
612
        let nid = NodeHierarchyItemId::from_crate_internal(Some(node));
613
        styled_dom
614
            .tag_ids_to_node_ids
615
            .iter()
616
            .find(|m| m.node_id == nid)
617
            .expect("the dropdown trigger must be hit-testable")
618
            .tag_id
619
            .inner
620
    }
621

            
622
    /// A `DomLayoutResult` carrying only a `styled_dom` plus (optionally) one
623
    /// forged hit-test area. The dropdown handler reaches exactly one geometry
624
    /// query (`get_node_hit_test_bounds`), which reads the display list only -
625
    /// no real layout (and no font) is needed.
626
    fn layout_result(styled_dom: StyledDom, anchor: Option<(NodeId, LogicalRect)>) -> DomLayoutResult {
627
        let mut display_list = DisplayList::default();
628
        if let Some((node, rect)) = anchor {
629
            let tag = tag_of(&styled_dom, node);
630
            display_list.items.push(DisplayListItem::HitTestArea {
631
                bounds: WindowLogicalRect::new(rect.origin, rect.size),
632
                // The tag TYPE matters: `get_node_hit_test_bounds` looks for a
633
                // DOM-node area specifically, because `tag.0` is also used by
634
                // text-run cursor areas with a colliding numbering scheme. A
635
                // forged area must therefore carry the same type the display
636
                // list builder stamps.
637
                tag: (tag, azul_core::hit_test::TAG_TYPE_DOM_NODE),
638
            });
639
        }
640

            
641
        DomLayoutResult {
642
            styled_dom,
643
            layout_tree: LayoutTree {
644
                nodes: Vec::new(),
645
                warm: Vec::new(),
646
                cold: Vec::new(),
647
                root: 0,
648
                dom_to_layout: BTreeMap::new(),
649
                children_arena: Vec::new(),
650
                children_offsets: Vec::new(),
651
                subtree_needs_intrinsic: Vec::new(),
652
            },
653
            calculated_positions: Vec::new(),
654
            viewport: LogicalRect::zero(),
655
            display_list: Arc::new(display_list),
656
            scroll_ids: HashMap::new(),
657
            scroll_id_to_node_id: HashMap::new(),
658
        }
659
    }
660

            
661
    /// Runs `f` with a callback environment over an empty `LayoutWindow` and no
662
    /// hit node - the "nothing to anchor to" case.
663
    fn with_env<R>(f: impl FnOnce(&Env<'_>) -> R) -> R {
664
        with_env_cfg(None, f)
665
    }
666

            
667
    /// Runs `f` with a callback environment whose root DOM is `styled_dom`, whose
668
    /// node `node` has the hit-test rect `rect`, and whose hit node is `node`.
669
    fn with_anchored_env<R>(
670
        styled_dom: StyledDom,
671
        node: NodeId,
672
        rect: LogicalRect,
673
        f: impl FnOnce(&Env<'_>) -> R,
674
    ) -> R {
675
        with_env_cfg(Some((styled_dom, node, rect)), f)
676
    }
677

            
678
    fn with_env_cfg<R>(
679
        anchored: Option<(StyledDom, NodeId, LogicalRect)>,
680
        f: impl FnOnce(&Env<'_>) -> R,
681
    ) -> R {
682
        let mut layout_window =
683
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
684

            
685
        let hit = match anchored {
686
            Some((styled_dom, node, rect)) => {
687
                layout_window
688
                    .layout_results
689
                    .insert(DomId::ROOT_ID, layout_result(styled_dom, Some((node, rect))));
690
                DomNodeId {
691
                    dom: DomId::ROOT_ID,
692
                    node: NodeHierarchyItemId::from_crate_internal(Some(node)),
693
                }
694
            }
695
            None => DomNodeId {
696
                dom: DomId::ROOT_ID,
697
                node: NodeHierarchyItemId::NONE,
698
            },
699
        };
700

            
701
        let renderer_resources = RendererResources::default();
702
        let previous_window_state: Option<FullWindowState> = None;
703
        let current_window_state = FullWindowState::default();
704
        let gl_context = OptionGlContextPtr::None;
705
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
706
            BTreeMap::new();
707
        let window_handle = RawWindowHandle::Unsupported;
708
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
709

            
710
        let ref_data = CallbackInfoRefData {
711
            layout_window: &layout_window,
712
            renderer_resources: &renderer_resources,
713
            previous_window_state: &previous_window_state,
714
            current_window_state: &current_window_state,
715
            gl_context: &gl_context,
716
            current_scroll_manager: &scroll_states,
717
            current_window_handle: &window_handle,
718
            system_callbacks: &system_callbacks,
719
            system_style: Arc::new(system::SystemStyle::default()),
720
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
721
            #[cfg(feature = "icu")]
722
            icu_localizer: IcuLocalizerHandle::default(),
723
            ctx: OptionRefAny::None,
724
        };
725

            
726
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
727
        let env = Env {
728
            ref_data: &ref_data,
729
            changes: &changes,
730
            hit,
731
        };
732
        f(&env)
733
    }
734

            
735
    /// The labels of a queued `OpenMenu` change's items, in menu order.
736
    fn menu_labels(menu: &Menu) -> Vec<String> {
737
        menu.items
738
            .as_ref()
739
            .iter()
740
            .map(|i| match i {
741
                MenuItem::String(s) => s.label.as_str().to_string(),
742
                other => panic!("the dropdown must only emit string items, got {other:?}"),
743
            })
744
            .collect()
745
    }
746

            
747
    // ==================================================================
748
    // DropDown::new / Default  (constructor invariants)
749
    // ==================================================================
750

            
751
    #[test]
752
    fn new_keeps_the_choices_and_starts_unselected_without_a_callback() {
753
        let dd = DropDown::new(choices(&["a", "b", "c"]));
754

            
755
        assert_eq!(dd.choices.len(), 3);
756
        assert_eq!(
757
            dd.choices.as_slice().iter().map(AzString::as_str).collect::<Vec<_>>(),
758
            vec!["a", "b", "c"],
759
            "choices must be stored verbatim, in order",
760
        );
761
        assert_eq!(dd.selected, 0, "a fresh dropdown selects the first item");
762
        assert!(
763
            dd.on_choice_change.as_ref().is_none(),
764
            "`new` must not invent a callback",
765
        );
766
    }
767

            
768
    #[test]
769
    fn new_preserves_every_adversarial_choice_byte_for_byte() {
770
        let originals = adversarial_choices();
771
        let dd = DropDown::new(StringVec::from_vec(
772
            originals.iter().cloned().map(AzString::from_string).collect(),
773
        ));
774

            
775
        assert_eq!(dd.choices.len(), originals.len(), "no choice may be dropped");
776
        for (stored, original) in dd.choices.as_slice().iter().zip(&originals) {
777
            assert_eq!(
778
                stored.as_str(),
779
                original.as_str(),
780
                "a NUL / bidi / astral label must survive the StringVec round-trip",
781
            );
782
            assert_eq!(
783
                stored.as_str().len(),
784
                original.len(),
785
                "byte length must be preserved — an embedded NUL must not truncate",
786
            );
787
        }
788
    }
789

            
790
    #[test]
791
    fn new_on_an_empty_choice_list_still_reports_index_zero() {
792
        // `selected == 0` points *past the end* of an empty list. That is the
793
        // documented starting state, so every consumer (notably `dom()`) has to
794
        // tolerate an out-of-range selection from the very first frame.
795
        let dd = DropDown::new(StringVec::from_const_slice(&[]));
796
        assert!(dd.choices.is_empty());
797
        assert_eq!(dd.selected, 0);
798
        assert!(dd.choices.as_slice().get(dd.selected).is_none());
799
    }
800

            
801
    #[test]
802
    fn new_with_ten_thousand_choices_keeps_len_and_capacity_consistent() {
803
        let n = 10_000;
804
        let dd = DropDown::new(StringVec::from_vec(
805
            (0..n).map(|i| AzString::from_string(i.to_string())).collect(),
806
        ));
807

            
808
        assert_eq!(dd.choices.len(), n);
809
        assert!(
810
            dd.choices.capacity() >= dd.choices.len(),
811
            "capacity must never be smaller than len",
812
        );
813
        assert_eq!(dd.choices.as_slice().len(), n, "the C slice view must agree with len");
814
        assert_eq!(dd.choices.as_slice()[n - 1].as_str(), (n - 1).to_string());
815
    }
816

            
817
    #[test]
818
    fn default_is_the_empty_unselected_dropdown() {
819
        let dd = DropDown::default();
820
        assert!(dd.choices.is_empty());
821
        assert_eq!(dd.selected, 0);
822
        assert!(dd.on_choice_change.as_ref().is_none());
823
        assert_eq!(dd, DropDown::new(StringVec::from_const_slice(&[])));
824
    }
825

            
826
    // ==================================================================
827
    // set_on_choice_change / with_on_choice_change
828
    // ==================================================================
829

            
830
    #[test]
831
    fn set_on_choice_change_stores_the_exact_refany_and_function_pointer() {
832
        let l = log();
833
        let data = RefAny::new(l);
834
        let mut dd = DropDown::new(choices(&["a"]));
835
        dd.set_on_choice_change(data.clone(), cb(record_choice));
836

            
837
        let stored = dd
838
            .on_choice_change
839
            .as_ref()
840
            .expect("the callback must be stored");
841
        assert_eq!(
842
            stored.refany, data,
843
            "the widget must hold the caller's allocation, not a copy",
844
        );
845
        assert_eq!(
846
            stored.callback.cb as usize, record_choice as usize,
847
            "the function pointer must round-trip unchanged",
848
        );
849
        assert!(
850
            stored.callback.ctx.as_ref().is_none(),
851
            "a native Rust callback has no FFI context",
852
        );
853
    }
854

            
855
    #[test]
856
    fn set_on_choice_change_is_last_write_wins() {
857
        let mut dd = DropDown::new(choices(&["a"]));
858
        dd.set_on_choice_change(RefAny::new(log()), cb(record_choice));
859
        let second = RefAny::new(log());
860
        dd.set_on_choice_change(second.clone(), cb(reject_choice));
861

            
862
        let stored = dd.on_choice_change.as_ref().expect("still exactly one callback");
863
        assert_eq!(stored.callback.cb as usize, reject_choice as usize);
864
        assert_eq!(stored.refany, second, "the second registration must replace the first");
865
        assert_ne!(
866
            record_choice as usize, reject_choice as usize,
867
            "the two probes must not have been folded into one symbol",
868
        );
869
    }
870

            
871
    #[test]
872
    fn set_on_choice_change_does_not_disturb_the_choices_or_the_selection() {
873
        let mut dd = adversarial_dropdown();
874
        dd.selected = usize::MAX;
875
        let before = dd.choices.clone();
876

            
877
        dd.set_on_choice_change(RefAny::new(log()), cb(record_choice));
878

            
879
        assert_eq!(dd.choices, before, "registering a callback must not touch the model");
880
        assert_eq!(dd.selected, usize::MAX, "…nor the selection, however out of range");
881
    }
882

            
883
    #[test]
884
    fn with_on_choice_change_is_the_setter_plus_a_move() {
885
        let data = RefAny::new(log());
886
        let built = DropDown::new(choices(&["a", "b"])).with_on_choice_change(data.clone(), cb(record_choice));
887

            
888
        let mut expected = DropDown::new(choices(&["a", "b"]));
889
        expected.set_on_choice_change(data, cb(record_choice));
890

            
891
        assert_eq!(built, expected, "the builder must not differ from the setter");
892
    }
893

            
894
    #[test]
895
    fn with_on_choice_change_preserves_an_out_of_range_selection() {
896
        let mut dd = DropDown::new(choices(&["a"]));
897
        dd.selected = usize::MAX;
898
        let dd = dd.with_on_choice_change(RefAny::new(log()), cb(record_choice));
899

            
900
        assert_eq!(dd.selected, usize::MAX, "the builder must not silently clamp");
901
        assert_eq!(dd.choices.len(), 1);
902
    }
903

            
904
    #[test]
905
    fn with_on_choice_change_accepts_a_zero_choice_dropdown() {
906
        let dd = DropDown::default().with_on_choice_change(RefAny::new(log()), cb(record_choice));
907
        assert!(dd.choices.is_empty());
908
        assert!(
909
            dd.on_choice_change.as_ref().is_some(),
910
            "a callback on an empty dropdown is legal — it just can never fire",
911
        );
912
    }
913

            
914
    // ==================================================================
915
    // swap_with_default
916
    // ==================================================================
917

            
918
    #[test]
919
    fn swap_with_default_moves_the_original_out_and_leaves_a_default() {
920
        let data = RefAny::new(log());
921
        let mut dd = DropDown::new(choices(&["a", "b"])).with_on_choice_change(data.clone(), cb(record_choice));
922
        dd.selected = 1;
923

            
924
        let taken = dd.swap_with_default();
925

            
926
        assert_eq!(taken.choices.len(), 2);
927
        assert_eq!(taken.selected, 1);
928
        assert_eq!(
929
            taken.on_choice_change.as_ref().expect("callback moved out").refany,
930
            data,
931
        );
932
        assert_eq!(dd, DropDown::default(), "what stays behind must be the default");
933
    }
934

            
935
    #[test]
936
    fn swap_with_default_is_idempotent_after_the_first_call() {
937
        let mut dd = adversarial_dropdown();
938
        let _first = dd.swap_with_default();
939
        let second = dd.swap_with_default();
940

            
941
        assert_eq!(second, DropDown::default(), "the second take yields a default");
942
        assert_eq!(dd, DropDown::default(), "…and leaves another default behind");
943
    }
944

            
945
    #[test]
946
    fn swap_with_default_preserves_an_out_of_range_selection_and_huge_labels() {
947
        let mut dd = adversarial_dropdown();
948
        dd.selected = usize::MAX;
949
        let n = dd.choices.len();
950

            
951
        let taken = dd.swap_with_default();
952

            
953
        assert_eq!(taken.selected, usize::MAX, "swap must not normalise anything");
954
        assert_eq!(taken.choices.len(), n);
955
        assert_eq!(dd.selected, 0);
956
        assert!(dd.choices.is_empty());
957
    }
958

            
959
    // ==================================================================
960
    // DropDown::dom
961
    // ==================================================================
962

            
963
    #[test]
964
    fn dom_labels_the_selected_choice() {
965
        for (idx, expected) in [(0, "alpha"), (1, "beta"), (2, "gamma")] {
966
            let mut dd = DropDown::new(choices(&["alpha", "beta", "gamma"]));
967
            dd.selected = idx;
968
            let dom = dd.dom();
969
            assert_eq!(label_of(&dom), expected, "index {idx} must label the trigger");
970
        }
971
    }
972

            
973
    #[test]
974
    fn dom_falls_back_to_an_empty_label_for_an_out_of_range_selection() {
975
        // len, len+1 and the arithmetic limit: `.get()` returns None for all of
976
        // them, and the documented fallback is the empty string — never a panic
977
        // and never a stale neighbour.
978
        for idx in [3_usize, 4, usize::MAX - 1, usize::MAX] {
979
            let mut dd = DropDown::new(choices(&["a", "b", "c"]));
980
            dd.selected = idx;
981
            let dom = dd.dom();
982
            assert_eq!(label_of(&dom), "", "selected = {idx} must render an empty label");
983
        }
984
    }
985

            
986
    #[test]
987
    fn dom_on_an_empty_dropdown_renders_an_empty_label() {
988
        let dom = DropDown::default().dom();
989
        assert_eq!(label_of(&dom), "");
990
        assert_eq!(dom.children.len(), 2, "label + arrow are rendered regardless");
991
    }
992

            
993
    #[test]
994
    fn dom_label_survives_unicode_embedded_nuls_and_huge_strings() {
995
        let originals = adversarial_choices();
996
        for (idx, original) in originals.iter().enumerate() {
997
            let mut dd = DropDown::new(StringVec::from_vec(
998
                originals.iter().cloned().map(AzString::from_string).collect(),
999
            ));
            dd.selected = idx;
            let dom = dd.dom();
            assert_eq!(
                label_of(&dom),
                original.as_str(),
                "label {idx} must reach the text node byte-for-byte",
            );
        }
    }
    #[test]
    fn dom_shape_is_a_trigger_with_a_wrapped_label_and_an_arrow_icon() {
        let dom = DropDown::new(choices(&["a"])).dom();
        assert!(matches!(dom.root.get_node_type(), NodeType::Div), "the trigger is a div");
        assert_eq!(dom.children.len(), 2, "exactly a label and an arrow");
        let kids = dom.children.as_ref();
        assert!(matches!(kids[0].root.get_node_type(), NodeType::P), "the label is block-formatted");
        assert_eq!(kids[0].children.len(), 1, "the <p> wraps exactly one text node");
        match kids[1].root.get_node_type() {
            NodeType::Icon(s) => assert_eq!(s.as_ref().as_str(), "arrow_drop_down"),
            other => panic!("expected the arrow icon, got {other:?}"),
        }
        assert!(kids[1].children.is_empty(), "the icon is a leaf");
    }
    #[test]
    fn dom_child_count_cache_is_honest_for_every_selection() {
        for idx in [0_usize, 1, 2, 99, usize::MAX] {
            let mut dd = DropDown::new(choices(&["a", "b"]));
            dd.selected = idx;
            let dom = dd.dom();
            assert_eq!(
                dom.estimated_total_children,
                count_descendants(&dom),
                "selected = {idx}: a stale cache makes the compact-DOM arena under-allocate",
            );
            assert_eq!(dom.estimated_total_children, 3, "p + text + icon");
        }
    }
    #[test]
    fn dom_marks_the_trigger_focusable_and_gives_it_the_widget_class() {
        let dom = DropDown::new(choices(&["a"])).dom();
        assert_eq!(
            dom.root.get_tab_index(),
            Some(TabIndex::Auto),
            "the popup opens on focus, so the trigger must be reachable by keyboard",
        );
        assert_eq!(classes(&dom), vec!["__azul-native-dropdown".to_string()]);
    }
    #[test]
    fn dom_registers_exactly_one_focus_received_callback() {
        let dom = DropDown::new(choices(&["a", "b"])).dom();
        let cbs = dom.root.callbacks.as_ref();
        assert_eq!(cbs.len(), 1, "one handler — a duplicate would open two popups");
        assert_eq!(cbs[0].event, EventFilter::Focus(FocusEventFilter::FocusReceived));
        assert_eq!(cbs[0].callback.cb, on_dropdown_click as usize);
        assert!(cbs[0].callback.ctx.as_ref().is_none());
    }
    #[test]
    fn dom_hands_the_whole_widget_to_the_callback_refany() {
        let mut dd = adversarial_dropdown();
        dd.selected = 4;
        let expected: Vec<String> = dd.choices.as_slice().iter().map(|c| c.as_str().to_string()).collect();
        let (_dom, mut refany) = rendered(dd);
        let stored = refany
            .downcast_ref::<DropDown>()
            .expect("dom() must store the DropDown itself, unwrapped");
        assert_eq!(stored.selected, 4);
        assert_eq!(
            stored.choices.as_slice().iter().map(|c| c.as_str().to_string()).collect::<Vec<_>>(),
            expected,
            "the handler must see the same choices the label was built from",
        );
    }
    #[test]
    fn from_dropdown_for_dom_renders_the_same_trigger_as_dom() {
        let mut dd = DropDown::new(choices(&["a", "b", "c"]));
        dd.selected = 2;
        let direct = dd.clone().dom();
        let converted = Dom::from(dd);
        assert_eq!(label_of(&direct), label_of(&converted));
        assert_eq!(direct.children.len(), converted.children.len());
        assert_eq!(classes(&direct), classes(&converted));
        assert_eq!(direct.estimated_total_children, converted.estimated_total_children);
    }
    #[test]
    fn dom_with_ten_thousand_choices_renders_only_the_selected_one() {
        let n = 10_000;
        let mut dd = DropDown::new(StringVec::from_vec(
            (0..n).map(|i| AzString::from_string(i.to_string())).collect(),
        ));
        dd.selected = n - 1;
        let dom = dd.dom();
        assert_eq!(label_of(&dom), (n - 1).to_string());
        assert_eq!(
            dom.estimated_total_children, 3,
            "the trigger must not materialise one node per choice",
        );
    }
    #[test]
    fn dom_does_not_mutate_the_selection_it_was_given() {
        // The widget is stateless w.r.t. selection: `dom()` reads `selected` and
        // never writes it. Anything that changes the label has to go through the
        // caller's own state, updated from the choice-change callback.
        let mut dd = DropDown::new(choices(&["a", "b"]));
        dd.selected = 1;
        let (_dom, mut refany) = rendered(dd);
        assert_eq!(refany.downcast_ref::<DropDown>().expect("stored widget").selected, 1);
    }
    // ==================================================================
    // on_dropdown_click
    // ==================================================================
    #[test]
    fn on_dropdown_click_ignores_a_refany_of_the_wrong_type() {
        with_env(|env| {
            let update = on_dropdown_click(RefAny::new(0_usize), env.info());
            assert_eq!(update, Update::DoNothing);
            assert!(
                env.take_changes().is_empty(),
                "a type mismatch must be a silent no-op, not a half-built menu",
            );
        });
    }
    #[test]
    fn on_dropdown_click_without_a_hit_node_opens_nothing() {
        let (_dom, refany) = rendered(DropDown::new(choices(&["a", "b"])));
        with_env(|env| {
            // The hit node is NONE and the window has no layout results, so the
            // popup has nothing to anchor to.
            let update = on_dropdown_click(refany.clone(), env.info());
            assert_eq!(update, Update::DoNothing);
            assert!(
                env.take_changes().is_empty(),
                "a failed anchor must not queue a half-open menu",
            );
        });
    }
    #[test]
    fn on_dropdown_click_opens_one_menu_item_per_choice_in_order() {
        let labels = ["a", "", "\u{5E9}\u{5DC}\u{5D5}\u{5DD}", "a\0b"];
        let (dom, refany) = rendered(DropDown::new(choices(&labels)));
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(10.0, 20.0), LogicalSize::new(100.0, 30.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            let update = on_dropdown_click(refany.clone(), env.info());
            assert_eq!(update, Update::DoNothing, "opening the popup is not a re-layout");
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected exactly one OpenMenu change");
            };
            assert_eq!(
                menu_labels(&menu),
                labels.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(),
                "menu order must mirror choice order, NULs and RTL included",
            );
        });
    }
    #[test]
    fn on_dropdown_click_on_an_empty_dropdown_opens_an_empty_menu() {
        let (dom, refany) = rendered(DropDown::default());
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(1.0, 1.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            assert_eq!(on_dropdown_click(refany.clone(), env.info()), Update::DoNothing);
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            assert!(menu.items.is_empty(), "no choices means no items — not a panic");
        });
    }
    #[test]
    fn on_dropdown_click_anchors_the_menu_below_the_trigger() {
        let (dom, refany) = rendered(DropDown::new(choices(&["a"])));
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(10.0, 20.0), LogicalSize::new(100.0, 30.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            on_dropdown_click(refany.clone(), env.info());
            let CallbackChange::OpenMenu { menu, position } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            let p = position.expect("the popup must be pinned to the trigger");
            assert_eq!((p.x, p.y), (10.0, 50.0), "bottom-left of the trigger rect");
            assert!(matches!(menu.position, MenuPopupPosition::BottomOfHitRect));
            assert!(matches!(menu.context_mouse_btn, ContextMenuMouseButton::Right));
        });
    }
    #[test]
    fn on_dropdown_click_is_repeatable_and_does_not_consume_the_widget() {
        let (dom, refany) = rendered(DropDown::new(choices(&["a", "b"])));
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            for round in 0..3 {
                on_dropdown_click(refany.clone(), env.info());
                let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                    panic!("round {round}: expected an OpenMenu change");
                };
                assert_eq!(menu_labels(&menu), vec!["a".to_string(), "b".to_string()]);
            }
        });
    }
    #[test]
    fn on_dropdown_click_tags_every_item_with_its_own_index_and_handler() {
        let l = log();
        let dd = DropDown::new(choices(&["a", "b", "c"]))
            .with_on_choice_change(RefAny::new(l), cb(record_choice));
        let (dom, refany) = rendered(dd);
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            on_dropdown_click(refany.clone(), env.info());
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            for (idx, item) in menu.items.as_ref().iter().enumerate() {
                let MenuItem::String(s) = item else {
                    panic!("item {idx} is not a string item");
                };
                let menu_cb = s.callback.as_ref().expect("every item must be clickable");
                assert_eq!(
                    menu_cb.callback.cb, on_choice_selected as usize,
                    "item {idx} must route through the widget's own handler",
                );
                let mut data = menu_cb.refany.clone();
                let payload = data
                    .downcast_ref::<ChoiceCallbackData>()
                    .expect("the item payload must be a ChoiceCallbackData");
                assert_eq!(payload.choice_id, idx, "item {idx} carries the wrong index");
                assert!(
                    payload.on_choice_change.as_ref().is_some(),
                    "item {idx} lost the user callback on the way into the menu",
                );
            }
        });
    }
    // ==================================================================
    // on_choice_selected
    // ==================================================================
    #[test]
    fn on_choice_selected_ignores_a_refany_of_the_wrong_type() {
        with_env(|env| {
            let update = on_choice_selected(RefAny::new(0_usize), env.info());
            assert_eq!(update, Update::DoNothing);
            assert!(env.take_changes().is_empty());
        });
    }
    #[test]
    fn on_choice_selected_without_a_registered_callback_does_nothing() {
        let data = RefAny::new(ChoiceCallbackData {
            choice_id: 7,
            on_choice_change: None.into(),
        });
        with_env(|env| {
            let update = on_choice_selected(data.clone(), env.info());
            assert_eq!(update, Update::DoNothing, "an unwired dropdown must stay silent");
            assert!(env.take_changes().is_empty());
        });
    }
    #[test]
    fn on_choice_selected_forwards_the_index_and_propagates_the_return_value() {
        let l = log();
        let data = RefAny::new(ChoiceCallbackData {
            choice_id: 2,
            on_choice_change: Some(DropDownOnChoiceChange {
                refany: RefAny::new(l.clone()),
                callback: cb(record_choice),
            })
            .into(),
        });
        with_env(|env| {
            let update = on_choice_selected(data.clone(), env.info());
            assert_eq!(update, Update::RefreshDom, "the user's Update must not be swallowed");
        });
        assert_eq!(entries(&l), vec![2], "the callback must see its own index");
    }
    #[test]
    fn on_choice_selected_forwards_usize_max_unchanged() {
        // `choice_id` is a plain index with no upper bound: the limit value must
        // pass through untouched rather than wrap, saturate or panic.
        let l = log();
        let data = RefAny::new(ChoiceCallbackData {
            choice_id: usize::MAX,
            on_choice_change: Some(DropDownOnChoiceChange {
                refany: RefAny::new(l.clone()),
                callback: cb(record_choice),
            })
            .into(),
        });
        with_env(|env| {
            assert_eq!(on_choice_selected(data.clone(), env.info()), Update::RefreshDom);
        });
        assert_eq!(entries(&l), vec![usize::MAX]);
    }
    #[test]
    fn on_choice_selected_is_repeatable_on_the_same_payload() {
        let l = log();
        let data = RefAny::new(ChoiceCallbackData {
            choice_id: 1,
            on_choice_change: Some(DropDownOnChoiceChange {
                refany: RefAny::new(l.clone()),
                callback: cb(record_choice),
            })
            .into(),
        });
        with_env(|env| {
            // The handler takes an *exclusive* borrow of the payload; if it were
            // ever leaked, the second call would fail to downcast and silently
            // return DoNothing.
            for _ in 0..3 {
                assert_eq!(on_choice_selected(data.clone(), env.info()), Update::RefreshDom);
            }
        });
        assert_eq!(entries(&l), vec![1, 1, 1], "the borrow must be released each time");
    }
    #[test]
    fn on_choice_selected_uses_the_callback_that_was_registered_last() {
        let l = log();
        let mut dd = DropDown::new(choices(&["a", "b"]));
        dd.set_on_choice_change(RefAny::new(l.clone()), cb(record_choice));
        dd.set_on_choice_change(RefAny::new(l.clone()), cb(reject_choice));
        let data = RefAny::new(ChoiceCallbackData {
            choice_id: 1,
            on_choice_change: dd.on_choice_change.clone(),
        });
        with_env(|env| {
            assert_eq!(on_choice_selected(data.clone(), env.info()), Update::RefreshDomAllWindows);
        });
        assert_eq!(entries(&l), vec![1 + SENTINEL], "the replaced callback must not fire");
    }
    // ==================================================================
    // End-to-end: focus -> popup -> pick an item
    // ==================================================================
    #[test]
    fn picking_a_menu_item_delivers_exactly_that_index_to_the_user_callback() {
        let l = log();
        let dd = DropDown::new(choices(&["a", "b", "c", "d"]))
            .with_on_choice_change(RefAny::new(l.clone()), cb(record_choice));
        let (dom, refany) = rendered(dd);
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            on_dropdown_click(refany.clone(), env.info());
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            // Deliberately out of order: the index must come from the item, not
            // from the order in which items happen to be clicked.
            for idx in [3_usize, 0, 2, 1] {
                let MenuItem::String(s) = &menu.items.as_ref()[idx] else {
                    panic!("item {idx} is not a string item");
                };
                let payload = s.callback.as_ref().expect("clickable").refany.clone();
                assert_eq!(
                    on_choice_selected(payload, env.info()),
                    Update::RefreshDom,
                    "item {idx} must reach the user callback",
                );
            }
        });
        assert_eq!(entries(&l), vec![3, 0, 2, 1]);
    }
    #[test]
    fn picking_an_item_does_not_move_the_widgets_own_selection() {
        // NOTE (documented behaviour, not an accident): `DropDown` never updates
        // its own `selected` field. Selection state lives with the caller, which
        // is why the trigger label only changes once the caller re-renders. If
        // this ever starts self-updating, this assertion is the tripwire.
        let l = log();
        let dd = DropDown::new(choices(&["a", "b"]))
            .with_on_choice_change(RefAny::new(l.clone()), cb(record_choice));
        let (dom, mut refany) = rendered(dd);
        let styled_dom = StyledDom::create_from_dom(dom);
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(4.0, 8.0));
        with_anchored_env(styled_dom, NodeId::new(0), rect, |env| {
            on_dropdown_click(refany.clone(), env.info());
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            let MenuItem::String(s) = &menu.items.as_ref()[1] else {
                panic!("item 1 is not a string item");
            };
            let payload = s.callback.as_ref().expect("clickable").refany.clone();
            on_choice_selected(payload, env.info());
        });
        assert_eq!(entries(&l), vec![1], "the pick was delivered");
        assert_eq!(
            refany.downcast_ref::<DropDown>().expect("stored widget").selected,
            0,
            "the widget's own `selected` stays where the caller put it",
        );
    }
}