1
//! Combobox widget — an editable text field with a click-toggled drop-down list
2
//! of options. A blend of [`crate::widgets::drop_down::DropDown`] (the list of
3
//! options + click-to-select-by-index + `on_select` callback) and
4
//! [`crate::widgets::text_input::TextInput`] (the editable text field on top: the
5
//! user may type a free value, with `get_text_changeset` insertion + backspace
6
//! deletion). The open/close show-hide mirrors
7
//! [`crate::widgets::popover::Popover`] (an absolutely-positioned panel toggled
8
//! via `set_css_property(display)`), but the panel here holds a list of clickable
9
//! options rather than a single native menu popup.
10
//!
11
//! Structure: a `position: relative` wrapper containing a focusable *input field*
12
//! (a text node + a drop-down arrow) followed by an absolutely-positioned
13
//! *options list*, hidden by default (`display: none`). A single shared
14
//! [`RefAny`] holding the [`ComboBoxStateWrapper`] is attached to every callback
15
//! (the field's toggle/text-input/key-down handlers and each option's click
16
//! handler) so all of them read and mutate the *same* state — clicking the field
17
//! flips `open` and shows/hides the list; clicking an option fills the field with
18
//! the option's label (`change_node_text`), sets `selected`, closes the list, and
19
//! invokes the optional user `on_select(state)` with the new [`ComboBoxState`].
20
//! The clicked option's index is derived from its position (counting previous
21
//! siblings), exactly like the index-by-position approach used elsewhere.
22
//!
23
//! TODO2 — type-to-filter is NOT implemented. Live "filter-as-you-type" requires
24
//! the option list to be RE-RENDERED (a DOM rebuild) from the typed text on every
25
//! keystroke. Azul widget handlers can only patch *live* state through
26
//! `info.set_css_property` / `info.change_node_text` (show/hide/restyle/retext an
27
//! existing node) — they cannot add/remove DOM nodes, so the visible option set
28
//! cannot be re-filtered from a handler with the tools the other widgets use. The
29
//! field is therefore genuinely *editable* (you can type a free value, which is
30
//! reported in [`ComboBoxState::text`]), and selecting from the *full* list works
31
//! — but the list does not shrink as you type. A future revision could rebuild
32
//! the list via a full relayout (`Update::RefreshDom`) driven by a user callback
33
//! that owns the items, once that is runtime-verifiable.
34
//!
35
//! TODO2 — like [`Popover`], the list is placed at a fixed offset below the field
36
//! (it does not measure the field's height, flip near a screen edge, escape an
37
//! `overflow: hidden` ancestor, or raise its z-order — it relies on being the
38
//! later sibling to paint on top). There is no click-outside / blur dismissal
39
//! (closing on focus-lost races the option click and could swallow the
40
//! selection); the list closes on selection or on clicking the field again.
41
//!
42
//! Key types: [`ComboBox`], [`ComboBoxState`], [`ComboBoxOnSelect`].
43

            
44
use alloc::{string::String, vec::Vec};
45

            
46
use azul_core::{
47
    callbacks::{CoreCallback, CoreCallbackData, Update},
48
    dom::{
49
        Dom, DomVec, EventFilter, FocusEventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class,
50
        IdOrClassVec, TabIndex,
51
    },
52
    refany::{OptionRefAny, RefAny},
53
    window::VirtualKeyCode,
54
};
55
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
56
use azul_css::{
57
    props::{
58
        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, StyleFontSize},
59
        layout::{LayoutDisplay, LayoutPosition, LayoutFlexGrow, LayoutMinWidth, LayoutFlexDirection, LayoutAlignItems, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutTop, LayoutLeft},
60
        property::{CssProperty, *},
61
        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleCursor, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextColor, StyleTextAlign, StyleUserSelect},
62
    },
63
    impl_option_inner, AzString, StringVec,
64
};
65

            
66
use crate::callbacks::{Callback, CallbackInfo};
67

            
68
static COMBOBOX_WRAPPER_CLASS: &[IdOrClass] =
69
    &[Class(AzString::from_const_str("__azul-native-combobox"))];
70
static COMBOBOX_INPUT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
71
    "__azul-native-combobox-input",
72
))];
73
static COMBOBOX_TEXT_CLASS: &[IdOrClass] =
74
    &[Class(AzString::from_const_str("__azul-native-combobox-text"))];
75
static COMBOBOX_ARROW_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
76
    "__azul-native-combobox-arrow",
77
))];
78
static COMBOBOX_LIST_CLASS: &[IdOrClass] =
79
    &[Class(AzString::from_const_str("__azul-native-combobox-list"))];
80
static COMBOBOX_OPTION_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
81
    "__azul-native-combobox-option",
82
))];
83

            
84
const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
85
const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
86
const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
87
    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
88

            
89
// ---- layout (logical px) ----
90
/// Fixed vertical offset of the list below the wrapper's top edge (a
91
/// simplification - see the module-level `TODO2`; the field is ~26px tall).
92
const LIST_OFFSET_Y: isize = 28;
93
/// Minimum width of the field and the list.
94
const MIN_WIDTH: isize = 160;
95
const RADIUS: isize = 4;
96
const ARROW_FONT_SIZE_PX: isize = 18;
97

            
98
// ---- colours ----
99
const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
100
const BORDER_COLOR: ColorU = ColorU { r: 172, g: 172, b: 172, a: 255 }; // #acacac
101
const BORDER_FOCUS: ColorU = ColorU { r: 66, g: 134, b: 244, a: 255 }; // #4286f4
102
const TEXT_COLOR: ColorU = ColorU { r: 51, g: 51, b: 51, a: 255 }; // #333333
103
const OPTION_HOVER_BG: ColorU = ColorU { r: 234, g: 244, b: 252, a: 255 }; // #eaf4fc
104

            
105
const WHITE_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(WHITE)];
106
const WHITE_BG_VEC: StyleBackgroundContentVec =
107
    StyleBackgroundContentVec::from_const_slice(WHITE_BG_ITEMS);
108
const OPTION_HOVER_BG_ITEMS: &[StyleBackgroundContent] =
109
    &[StyleBackgroundContent::Color(OPTION_HOVER_BG)];
110
const OPTION_HOVER_BG_VEC: StyleBackgroundContentVec =
111
    StyleBackgroundContentVec::from_const_slice(OPTION_HOVER_BG_ITEMS);
112

            
113
/// Callback invoked when an option is chosen. The [`ComboBoxState`] carries the
114
/// new `selected` index and the field `text` (set to the chosen label).
115
pub type ComboBoxOnSelectCallbackType = extern "C" fn(RefAny, CallbackInfo, ComboBoxState) -> Update;
116
impl_widget_callback!(
117
    ComboBoxOnSelect,
118
    OptionComboBoxOnSelect,
119
    ComboBoxOnSelectCallback,
120
    ComboBoxOnSelectCallbackType
121
);
122

            
123
azul_core::impl_managed_callback! {
124
    wrapper:        ComboBoxOnSelectCallback,
125
    info_ty:        CallbackInfo,
126
    return_ty:      Update,
127
    default_ret:    Update::DoNothing,
128
    invoker_static: COMBOBOX_ON_SELECT_INVOKER,
129
    invoker_ty:     AzComboBoxOnSelectCallbackInvoker,
130
    thunk_fn:       az_combobox_on_select_callback_thunk,
131
    setter_fn:      AzApp_setComboBoxOnSelectCallbackInvoker,
132
    from_handle_fn: AzComboBoxOnSelectCallback_createFromHostHandle,
133
    extra_args:     [ state: ComboBoxState ],
134
}
135

            
136
/// An editable filtered-select widget: a text field plus a click-toggled list of
137
/// options.
138
#[derive(Debug, Clone, PartialEq, Eq)]
139
#[repr(C)]
140
pub struct ComboBox {
141
    /// Runtime state (`open`/`selected`/`text`) plus the item list and the
142
    /// optional select callback.
143
    pub combo_state: ComboBoxStateWrapper,
144
    /// Greyed text shown in the field when no value has been typed/selected.
145
    pub placeholder: AzString,
146
    /// Style of the outer wrapper (the `position: relative` context).
147
    pub wrapper_style: CssPropertyWithConditionsVec,
148
    /// Style of the clickable, focusable, editable input field.
149
    pub field_style: CssPropertyWithConditionsVec,
150
    /// Style of the text inside the field.
151
    pub text_style: CssPropertyWithConditionsVec,
152
    /// Style of the drop-down arrow icon on the right of the field.
153
    pub arrow_style: CssPropertyWithConditionsVec,
154
    /// Style of each option row inside the list panel.
155
    pub option_style: CssPropertyWithConditionsVec,
156
    /// Extra properties appended to the options-list panel style. The
157
    /// open/close `display` toggle stays widget-managed; anything here wins
158
    /// over the built-in panel style (inline properties resolve last-wins).
159
    pub list_style: CssPropertyWithConditionsVec,
160
}
161

            
162
#[derive(Debug, Clone, PartialEq, Eq)]
163
#[repr(C)]
164
pub struct ComboBoxStateWrapper {
165
    /// The mutable per-interaction state passed to `on_select`.
166
    pub inner: ComboBoxState,
167
    /// The full set of selectable options (rendered into the list).
168
    pub items: StringVec,
169
    /// Optional: function to call when an option is selected.
170
    pub on_select: OptionComboBoxOnSelect,
171
}
172

            
173
impl Default for ComboBoxStateWrapper {
174
    fn default() -> Self {
175
        Self {
176
            inner: ComboBoxState::default(),
177
            items: StringVec::from_const_slice(&[]),
178
            on_select: None.into(),
179
        }
180
    }
181
}
182

            
183
/// The live state of a [`ComboBox`]: whether the list is open, the currently
184
/// selected index, and the current (editable) field text.
185
#[derive(Debug, Clone, PartialEq, Eq)]
186
#[repr(C)]
187
pub struct ComboBoxState {
188
    /// `true` = list shown, `false` (default) = list hidden.
189
    pub open: bool,
190
    /// Zero-based index of the most recently selected option.
191
    pub selected: usize,
192
    /// The current text shown in the field (typed or set from a selection).
193
    pub text: AzString,
194
}
195

            
196
impl Default for ComboBoxState {
197
100
    fn default() -> Self {
198
100
        Self {
199
100
            open: false,
200
100
            selected: 0,
201
100
            text: AzString::from_const_str(""),
202
100
        }
203
100
    }
204
}
205

            
206
// ---- styles ----
207

            
208
/// Wrapper: an inline-block positioning context so the absolutely-positioned list
209
/// is placed relative to it.
210
static COMBOBOX_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
211
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
212
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
213
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
214
    CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
215
        MIN_WIDTH,
216
    ))),
217
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
218
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
219
];
220

            
221
/// The clickable, focusable, editable input field (text + arrow).
222
static COMBOBOX_INPUT_STYLE: &[CssPropertyWithConditions] = &[
223
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
224
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
225
    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
226
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
227
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
228
    // padding: 3px 4px
229
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(3))),
230
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
231
        LayoutPaddingBottom::const_px(3),
232
    )),
233
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
234
        4,
235
    ))),
236
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
237
        LayoutPaddingRight::const_px(4),
238
    )),
239
    // border: 1px solid #acacac
240
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
241
        LayoutBorderTopWidth::const_px(1),
242
    )),
243
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
244
        LayoutBorderBottomWidth::const_px(1),
245
    )),
246
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
247
        LayoutBorderLeftWidth::const_px(1),
248
    )),
249
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
250
        LayoutBorderRightWidth::const_px(1),
251
    )),
252
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
253
        inner: BorderStyle::Solid,
254
    })),
255
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
256
        StyleBorderBottomStyle {
257
            inner: BorderStyle::Solid,
258
        },
259
    )),
260
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
261
        inner: BorderStyle::Solid,
262
    })),
263
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
264
        StyleBorderRightStyle {
265
            inner: BorderStyle::Solid,
266
        },
267
    )),
268
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
269
        inner: BORDER_COLOR,
270
    })),
271
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
272
        StyleBorderBottomColor {
273
            inner: BORDER_COLOR,
274
        },
275
    )),
276
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
277
        inner: BORDER_COLOR,
278
    })),
279
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
280
        StyleBorderRightColor {
281
            inner: BORDER_COLOR,
282
        },
283
    )),
284
    // border-radius: 4px
285
    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
286
        StyleBorderTopLeftRadius::const_px(RADIUS),
287
    )),
288
    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
289
        StyleBorderTopRightRadius::const_px(RADIUS),
290
    )),
291
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
292
        StyleBorderBottomLeftRadius::const_px(RADIUS),
293
    )),
294
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
295
        StyleBorderBottomRightRadius::const_px(RADIUS),
296
    )),
297
    CssPropertyWithConditions::simple(CssProperty::const_background_content(WHITE_BG_VEC)),
298
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
299
        inner: TEXT_COLOR,
300
    })),
301
    // focus: highlight border
302
    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
303
        inner: BORDER_FOCUS,
304
    })),
305
    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
306
        StyleBorderBottomColor {
307
            inner: BORDER_FOCUS,
308
        },
309
    )),
310
    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(StyleBorderLeftColor {
311
        inner: BORDER_FOCUS,
312
    })),
313
    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
314
        StyleBorderRightColor {
315
            inner: BORDER_FOCUS,
316
        },
317
    )),
318
];
319

            
320
/// The editable text inside the field - takes the remaining horizontal space.
321
static COMBOBOX_TEXT_STYLE: &[CssPropertyWithConditions] = &[
322
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
323
    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
324
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
325
        LayoutPaddingRight::const_px(4),
326
    )),
327
];
328

            
329
/// The drop-down arrow icon on the right of the field.
330
static COMBOBOX_ARROW_STYLE: &[CssPropertyWithConditions] = &[
331
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
332
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(
333
        ARROW_FONT_SIZE_PX,
334
    ))),
335
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
336
];
337

            
338
/// Builds the floating options-list style. Only the `display` (open vs closed)
339
/// differs; all positioning/visual props are present in both so the runtime
340
/// `set_css_property(display)` toggle has everything it needs (mirroring the
341
/// popover/accordion approach).
342
69
fn build_list_style(open: bool) -> CssPropertyWithConditionsVec {
343
69
    let display = if open {
344
7
        LayoutDisplay::Block
345
    } else {
346
62
        LayoutDisplay::None
347
    };
348
69
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
349
69
        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
350
69
        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
351
69
        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(LIST_OFFSET_Y))),
352
69
        CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
353
69
        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
354
            MIN_WIDTH,
355
        ))),
356
        // border: 1px solid #acacac
357
69
        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
358
69
            LayoutBorderTopWidth::const_px(1),
359
        )),
360
69
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
361
69
            LayoutBorderBottomWidth::const_px(1),
362
        )),
363
69
        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
364
69
            LayoutBorderLeftWidth::const_px(1),
365
        )),
366
69
        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
367
69
            LayoutBorderRightWidth::const_px(1),
368
        )),
369
69
        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
370
69
            inner: BorderStyle::Solid,
371
69
        })),
372
69
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
373
69
            StyleBorderBottomStyle {
374
69
                inner: BorderStyle::Solid,
375
69
            },
376
        )),
377
69
        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
378
69
            inner: BorderStyle::Solid,
379
69
        })),
380
69
        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
381
69
            StyleBorderRightStyle {
382
69
                inner: BorderStyle::Solid,
383
69
            },
384
        )),
385
69
        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
386
69
            inner: BORDER_COLOR,
387
69
        })),
388
69
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
389
69
            StyleBorderBottomColor {
390
69
                inner: BORDER_COLOR,
391
69
            },
392
        )),
393
69
        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
394
69
            inner: BORDER_COLOR,
395
69
        })),
396
69
        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
397
69
            StyleBorderRightColor {
398
69
                inner: BORDER_COLOR,
399
69
            },
400
        )),
401
        // border-radius: 4px
402
69
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
403
69
            StyleBorderBottomLeftRadius::const_px(RADIUS),
404
        )),
405
69
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
406
69
            StyleBorderBottomRightRadius::const_px(RADIUS),
407
        )),
408
69
        CssPropertyWithConditions::simple(CssProperty::const_background_content(WHITE_BG_VEC)),
409
    ])
410
69
}
411

            
412
/// Per-option row style: a padded, pointer-cursor block highlighted on hover.
413
static COMBOBOX_OPTION_STYLE: &[CssPropertyWithConditions] = &[
414
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
415
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(6))),
416
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
417
        LayoutPaddingBottom::const_px(6),
418
    )),
419
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
420
        10,
421
    ))),
422
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
423
        LayoutPaddingRight::const_px(10),
424
    )),
425
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
426
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
427
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
428
        inner: TEXT_COLOR,
429
    })),
430
    CssPropertyWithConditions::on_hover(CssProperty::const_background_content(OPTION_HOVER_BG_VEC)),
431
];
432

            
433
impl ComboBox {
434
    /// Creates a new combobox with the given options (no callback, nothing typed).
435
96
    #[must_use] pub fn new(items: StringVec) -> Self {
436
96
        Self {
437
96
            combo_state: ComboBoxStateWrapper {
438
96
                inner: ComboBoxState::default(),
439
96
                items,
440
96
                on_select: None.into(),
441
96
            },
442
96
            placeholder: AzString::from_const_str(""),
443
96
            wrapper_style: CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_WRAPPER_STYLE),
444
96
            field_style: CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_INPUT_STYLE),
445
96
            text_style: CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_TEXT_STYLE),
446
96
            arrow_style: CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_ARROW_STYLE),
447
96
            option_style: CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_OPTION_STYLE),
448
96
            list_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
449
96
        }
450
96
    }
451

            
452
    /// Creates an empty combobox.
453
25
    #[must_use] pub fn create() -> Self {
454
25
        Self::new(StringVec::from_const_slice(&[]))
455
25
    }
456

            
457
    /// Sets the initially-selected option index.
458
    #[inline]
459
20
    pub const fn set_selected(&mut self, selected: usize) {
460
20
        self.combo_state.inner.selected = selected;
461
20
    }
462

            
463
    /// Builder-style setter for the initially-selected index.
464
    #[inline]
465
8
    #[must_use] pub const fn with_selected(mut self, selected: usize) -> Self {
466
8
        self.set_selected(selected);
467
8
        self
468
8
    }
469

            
470
    /// Sets the initial (editable) field text.
471
    #[inline]
472
17
    pub fn set_text(&mut self, text: AzString) {
473
17
        self.combo_state.inner.text = text;
474
17
    }
475

            
476
    /// Builder-style setter for the initial field text.
477
    #[inline]
478
8
    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
479
8
        self.set_text(text);
480
8
        self
481
8
    }
482

            
483
    /// Sets the greyed placeholder shown when the field is empty.
484
    #[inline]
485
8
    pub fn set_placeholder(&mut self, placeholder: AzString) {
486
8
        self.placeholder = placeholder;
487
8
    }
488

            
489
    /// Builder-style setter for the placeholder.
490
    #[inline]
491
7
    #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
492
7
        self.set_placeholder(placeholder);
493
7
        self
494
7
    }
495

            
496
    /// Sets the callback invoked when an option is selected.
497
    #[inline]
498
5
    pub fn set_on_select<C: Into<ComboBoxOnSelectCallback>>(&mut self, data: RefAny, on_select: C) {
499
5
        self.combo_state.on_select = Some(ComboBoxOnSelect {
500
5
            callback: on_select.into(),
501
5
            refany: data,
502
5
        })
503
5
        .into();
504
5
    }
505

            
506
    /// Builder-style setter for the select callback.
507
    #[inline]
508
2
    #[must_use] pub fn with_on_select<C: Into<ComboBoxOnSelectCallback>>(
509
2
        mut self,
510
2
        data: RefAny,
511
2
        on_select: C,
512
2
    ) -> Self {
513
2
        self.set_on_select(data, on_select);
514
2
        self
515
2
    }
516

            
517
    /// Replaces `self` with a default (empty) combobox and returns the original.
518
    #[inline]
519
2
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
520
2
        let mut s = Self::create();
521
2
        core::mem::swap(&mut s, self);
522
2
        s
523
2
    }
524

            
525
    /// Renders the combobox into a [`Dom`] subtree with the `__azul-native-combobox`
526
    /// class.
527
57
    #[must_use] pub fn dom(self) -> Dom {
528
        // Initial field text: the typed/selected text if present, else the
529
        // placeholder (a simplification — there is no separate placeholder node,
530
        // so the placeholder is just the initial label and is replaced on the
531
        // first keystroke or selection).
532
57
        let field_text = if self.combo_state.inner.text.as_str().is_empty() {
533
53
            self.placeholder.clone()
534
        } else {
535
4
            self.combo_state.inner.text.clone()
536
        };
537

            
538
57
        let open = self.combo_state.inner.open;
539
57
        let items = self.combo_state.items.clone();
540

            
541
        // ONE shared RefAny: the field handlers and every option handler all
542
        // read/mutate the same ComboBoxStateWrapper (the text_input shared-state
543
        // pattern), so open/selected/text stay in sync across interactions.
544
57
        let state_ref = RefAny::new(self.combo_state);
545

            
546
57
        let text_node = Dom::create_p_with_text(field_text)
547
57
            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_TEXT_CLASS))
548
57
            .with_css_props(self.text_style);
549

            
550
57
        let arrow = Dom::create_icon(AzString::from_const_str("arrow_drop_down"))
551
57
            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_ARROW_CLASS))
552
57
            .with_css_props(self.arrow_style);
553

            
554
        // The focusable, editable input field. Clicking it toggles the list
555
        // (Hover::MouseUp) and focuses it; typing edits the text node
556
        // (Focus::TextInput / VirtualKeyDown), mirroring text_input.
557
57
        let field = Dom::create_div()
558
57
            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_INPUT_CLASS))
559
57
            .with_css_props(self.field_style)
560
57
            .with_tab_index(TabIndex::Auto)
561
57
            .with_callbacks(
562
57
                alloc::vec![
563
57
                    CoreCallbackData {
564
57
                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
565
57
                        callback: CoreCallback {
566
57
                            cb: on_combobox_toggle as usize,
567
57
                            ctx: OptionRefAny::None,
568
57
                        },
569
57
                        refany: state_ref.clone(),
570
57
                    },
571
57
                    CoreCallbackData {
572
57
                        event: EventFilter::Focus(FocusEventFilter::TextInput),
573
57
                        callback: CoreCallback {
574
57
                            cb: on_combobox_text_input as usize,
575
57
                            ctx: OptionRefAny::None,
576
57
                        },
577
57
                        refany: state_ref.clone(),
578
57
                    },
579
57
                    CoreCallbackData {
580
57
                        event: EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
581
57
                        callback: CoreCallback {
582
57
                            cb: on_combobox_key_down as usize,
583
57
                            ctx: OptionRefAny::None,
584
57
                        },
585
57
                        refany: state_ref.clone(),
586
57
                    },
587
                ]
588
57
                .into(),
589
            )
590
57
            .with_children(DomVec::from_vec(alloc::vec![text_node, arrow]));
591

            
592
        // Build the option rows. Each carries a CLONE of the shared state so its
593
        // click handler can mutate selected/open and read the chosen label.
594
57
        let mut option_doms: Vec<Dom> = Vec::with_capacity(items.as_ref().len());
595
782
        for option in items.as_ref() {
596
782
            option_doms.push(
597
782
                Dom::create_p_with_text(option.clone())
598
782
                    .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_OPTION_CLASS))
599
782
                    .with_css_props(self.option_style.clone())
600
782
                    .with_tab_index(TabIndex::Auto)
601
782
                    .with_callbacks(
602
782
                        alloc::vec![CoreCallbackData {
603
782
                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
604
782
                            callback: CoreCallback {
605
782
                                cb: on_combobox_option_click as usize,
606
782
                                ctx: OptionRefAny::None,
607
782
                            },
608
782
                            refany: state_ref.clone(),
609
782
                        }]
610
782
                        .into(),
611
782
                    ),
612
782
            );
613
782
        }
614

            
615
        // Widget-managed panel style (open/close display toggle) + caller
616
        // extras appended last so they win (inline resolution is last-wins).
617
57
        let list_style = if self.list_style.is_empty() {
618
57
            build_list_style(open)
619
        } else {
620
            let mut merged = build_list_style(open).into_library_owned_vec();
621
            merged.extend(self.list_style.as_ref().iter().cloned());
622
            CssPropertyWithConditionsVec::from_vec(merged)
623
        };
624

            
625
57
        let list = Dom::create_div()
626
57
            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_LIST_CLASS))
627
57
            .with_css_props(list_style)
628
57
            .with_children(DomVec::from_vec(option_doms));
629

            
630
57
        Dom::create_div()
631
57
            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_WRAPPER_CLASS))
632
57
            .with_css_props(self.wrapper_style)
633
            // children: [field, list] — the list is the field's next sibling.
634
57
            .with_children(DomVec::from_vec(alloc::vec![field, list]))
635
57
    }
636
}
637

            
638
impl Default for ComboBox {
639
1
    fn default() -> Self {
640
1
        Self::create()
641
1
    }
642
}
643

            
644
/// Field click handler. The hit node is the field; its next sibling is the list.
645
/// Flips `open` on the shared state and shows/hides the list via `display`.
646
6
extern "C" fn on_combobox_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
647
6
    let field = info.get_hit_node();
648
6
    let Some(list) = info.get_next_sibling(field) else {
649
3
        return Update::DoNothing;
650
    };
651

            
652
2
    let now_open = {
653
3
        let Some(mut combo) = data.downcast_mut::<ComboBoxStateWrapper>() else {
654
1
            return Update::DoNothing;
655
        };
656
2
        combo.inner.open = !combo.inner.open;
657
2
        combo.inner.open
658
    };
659

            
660
    // TODO2: shows/hides the list by toggling `display` via set_css_property; the
661
    // display:none/block relayout itself is not GUI-verified in this build.
662
2
    let display = if now_open {
663
1
        LayoutDisplay::Block
664
    } else {
665
1
        LayoutDisplay::None
666
    };
667
2
    info.set_css_property(list, CssProperty::const_display(display));
668

            
669
2
    Update::DoNothing
670
6
}
671

            
672
/// Field text-input handler - appends the typed character(s) to the editable
673
/// field text (mirroring `text_input`). Does NOT re-filter the list (see the
674
/// module-level type-to-filter `TODO2`).
675
11
extern "C" fn on_combobox_text_input(data: RefAny, info: CallbackInfo) -> Update {
676
11
    on_combobox_text_input_inner(data, info).unwrap_or(Update::DoNothing)
677
11
}
678

            
679
13
fn on_combobox_text_input_inner(mut data: RefAny, mut info: CallbackInfo) -> Option<Update> {
680
13
    let field = info.get_hit_node();
681
    // field -> label `<p>` -> bare text leaf: the label convention keeps the
682
    // styling on the block box, so the re-textable node is one level deeper.
683
13
    let text_node = info.get_first_child(info.get_first_child(field)?)?;
684

            
685
12
    let changeset = info.get_text_changeset()?;
686
10
    let inserted_text = changeset.inserted_text.as_str().to_string();
687
10
    if inserted_text.is_empty() {
688
1
        return None;
689
9
    }
690

            
691
8
    let new_text = {
692
9
        let mut combo = data.downcast_mut::<ComboBoxStateWrapper>()?;
693
8
        let mut s: String = combo.inner.text.as_str().into();
694
8
        s.push_str(&inserted_text);
695
8
        combo.inner.text = s.clone().into();
696
8
        s
697
    };
698

            
699
8
    info.change_node_text(text_node, new_text.into());
700
8
    Some(Update::DoNothing)
701
13
}
702

            
703
/// Field key-down handler - implements backspace deletion (mirroring `text_input`).
704
13
extern "C" fn on_combobox_key_down(data: RefAny, info: CallbackInfo) -> Update {
705
13
    on_combobox_key_down_inner(data, info).unwrap_or(Update::DoNothing)
706
13
}
707

            
708
15
fn on_combobox_key_down_inner(mut data: RefAny, mut info: CallbackInfo) -> Option<Update> {
709
15
    let field = info.get_hit_node();
710
    // field -> label `<p>` -> bare text leaf (see `on_combobox_text_input_inner`).
711
15
    let text_node = info.get_first_child(info.get_first_child(field)?)?;
712

            
713
14
    let keyboard_state = info.get_current_keyboard_state();
714
14
    let c = keyboard_state.current_virtual_keycode.into_option()?;
715
13
    if c != VirtualKeyCode::Back {
716
6
        return None;
717
7
    }
718

            
719
6
    let new_text = {
720
7
        let mut combo = data.downcast_mut::<ComboBoxStateWrapper>()?;
721
6
        let mut s: String = combo.inner.text.as_str().into();
722
6
        s.pop();
723
6
        combo.inner.text = s.clone().into();
724
6
        s
725
    };
726

            
727
6
    info.change_node_text(text_node, new_text.into());
728
6
    Some(Update::DoNothing)
729
15
}
730

            
731
/// Option click handler. The hit node is the clicked option's `<p>`; its index is
732
/// the number of previous siblings. Its parent is the list; the list's parent is
733
/// the wrapper, whose first child is the field, whose first child is the label
734
/// `<p>`, whose only child is the text node.
735
/// Fills the field with the option's label, sets `selected`, closes the list, and
736
/// invokes the optional user callback.
737
15
extern "C" fn on_combobox_option_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
738
15
    let option = info.get_hit_node();
739

            
740
    // index = number of previous siblings.
741
15
    let mut index = 0usize;
742
15
    let mut cursor = option;
743
226
    while let Some(prev) = info.get_previous_sibling(cursor) {
744
211
        index += 1;
745
211
        cursor = prev;
746
211
    }
747

            
748
15
    let Some(list) = info.get_parent(option) else {
749
2
        return Update::DoNothing;
750
    };
751
13
    let Some(wrapper) = info.get_parent(list) else {
752
        return Update::DoNothing;
753
    };
754
13
    let Some(field) = info.get_first_child(wrapper) else {
755
        return Update::DoNothing;
756
    };
757
13
    let Some(text_box) = info.get_first_child(field) else {
758
        return Update::DoNothing;
759
    };
760
13
    let Some(text_node) = info.get_first_child(text_box) else {
761
        return Update::DoNothing;
762
    };
763

            
764
10
    let (label, inner, result) = {
765
13
        let Some(mut combo) = data.downcast_mut::<ComboBoxStateWrapper>() else {
766
1
            return Update::DoNothing;
767
        };
768
12
        let Some(label) = combo.items.as_ref().get(index).cloned() else {
769
2
            return Update::DoNothing;
770
        };
771
10
        combo.inner.selected = index;
772
10
        combo.inner.text = label.clone();
773
10
        combo.inner.open = false;
774
10
        let inner = combo.inner.clone();
775
10
        let combo = &mut *combo;
776
10
        let result = match combo.on_select.as_mut() {
777
2
            Some(ComboBoxOnSelect { callback, refany }) => {
778
2
                (callback.cb)(refany.clone(), info, inner.clone())
779
            }
780
8
            None => Update::DoNothing,
781
        };
782
10
        (label, inner, result)
783
    };
784
10
    drop(inner);
785

            
786
    // Fill the field with the chosen label and close the list.
787
10
    info.change_node_text(text_node, label);
788
10
    info.set_css_property(list, CssProperty::const_display(LayoutDisplay::None));
789

            
790
10
    result
791
15
}
792

            
793
impl From<ComboBox> for Dom {
794
1
    fn from(c: ComboBox) -> Self {
795
1
        c.dom()
796
1
    }
797
}
798

            
799
#[cfg(all(test, feature = "std"))]
800
#[allow(clippy::too_many_lines)]
801
// `redundant_closure`: NOT redundant here. `run()` takes
802
// `impl FnOnce(RefAny, CallbackInfo) -> R`; `CallbackInfo` carries an elided
803
// lifetime, so the bound is higher-ranked (`for<'a> FnOnce(_, CallbackInfo<'a>)`).
804
// The handlers are `extern "C" fn` items, which do NOT satisfy a higher-ranked
805
// `FnOnce` bound — passing one bare fails to compile with E0277. The `|r, ci| f(r, ci)`
806
// wrapper is what makes the coercion happen and must stay.
807
#[allow(clippy::redundant_closure)]
808
mod autotest_generated {
809
    use std::{
810
        cell::{Cell, RefCell},
811
        collections::{BTreeMap, HashMap},
812
        sync::{Arc, Mutex},
813
    };
814

            
815
    use azul_core::{
816
        dom::{DomId, DomNodeId, NodeId, NodeType},
817
        geom::{LogicalRect, OptionLogicalPosition},
818
        gl::OptionGlContextPtr,
819
        hit_test::ScrollPosition,
820
        resources::RendererResources,
821
        styled_dom::{NodeHierarchyItemId, StyledDom},
822
        window::{MonitorVec, RawWindowHandle},
823
    };
824
    use azul_css::system::SystemStyle;
825
    use rust_fontconfig::FcFontCache;
826

            
827
    use super::*;
828
    #[cfg(feature = "icu")]
829
    use crate::icu::IcuLocalizerHandle;
830
    use crate::{
831
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
832
        managers::text_input::PendingTextEdit,
833
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
834
        window::{DomLayoutResult, LayoutWindow},
835
        window_state::FullWindowState,
836
    };
837

            
838
    // ------------------------------------------------------------------
839
    // Fixtures / helpers
840
    // ------------------------------------------------------------------
841

            
842
    /// A `StringVec` of options from string literals.
843
    fn sv(items: &[&str]) -> StringVec {
844
        StringVec::from_vec(items.iter().map(|s| AzString::from(*s)).collect())
845
    }
846

            
847
    /// True if `node` carries the CSS class `name`.
848
    fn has_class(node: &Dom, name: &str) -> bool {
849
        node.root
850
            .get_ids_and_classes()
851
            .as_ref()
852
            .iter()
853
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
854
    }
855

            
856
    /// The text of a text node, looking through the `<p>` block wrapper the
857
    /// label convention mandates (`p > text`).
858
    fn text_of(node: &Dom) -> Option<&str> {
859
        match node.root.get_node_type() {
860
            NodeType::Text(s) => Some(s.as_ref().as_str()),
861
            NodeType::P => match node.children.as_ref() {
862
                [only] => match only.root.get_node_type() {
863
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
864
                    _ => None,
865
                },
866
                _ => None,
867
            },
868
            _ => None,
869
        }
870
    }
871

            
872
    /// The icon name of a `NodeType::Icon` node.
873
    fn icon_of(node: &Dom) -> Option<&str> {
874
        match node.root.get_node_type() {
875
            NodeType::Icon(s) => Some(s.as_ref().as_str()),
876
            _ => None,
877
        }
878
    }
879

            
880
    /// The `display` value in a node's *inline* style, if it sets one.
881
    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
882
        node.root
883
            .style
884
            .iter_inline_properties()
885
            .find_map(|(p, _)| match p {
886
                CssProperty::Display(v) => v.get_property().copied(),
887
                _ => None,
888
            })
889
    }
890

            
891
    /// The `display` declared in a built style vec.
892
    fn display_of(props: &CssPropertyWithConditionsVec) -> Option<LayoutDisplay> {
893
        props.as_ref().iter().find_map(|p| match &p.property {
894
            CssProperty::Display(v) => v.get_property().copied(),
895
            _ => None,
896
        })
897
    }
898

            
899
    /// The `position` declared in a built style vec.
900
    fn position_of(props: &CssPropertyWithConditionsVec) -> Option<LayoutPosition> {
901
        props.as_ref().iter().find_map(|p| match &p.property {
902
            CssProperty::Position(v) => v.get_property().copied(),
903
            _ => None,
904
        })
905
    }
906

            
907
    /// `(field, list)` of a rendered combobox DOM.
908
    fn parts(dom: &Dom) -> (&Dom, &Dom) {
909
        let children = dom.children.as_ref();
910
        assert_eq!(children.len(), 2, "a combobox is exactly [field, list]");
911
        (&children[0], &children[1])
912
    }
913

            
914
    /// Flattened indices of every node carrying `class`, in tree order. Used
915
    /// instead of hard-coded indices so the tests do not encode the DOM
916
    /// flattening order.
917
    fn nodes_with_class(styled: &StyledDom, class: &str) -> Vec<usize> {
918
        styled
919
            .node_data
920
            .as_ref()
921
            .iter()
922
            .enumerate()
923
            .filter(|(_, nd)| {
924
                nd.get_ids_and_classes()
925
                    .as_ref()
926
                    .iter()
927
                    .any(|c| matches!(c, Class(s) if s.as_str() == class))
928
            })
929
            .map(|(i, _)| i)
930
            .collect()
931
    }
932

            
933
    /// A styled `ComboBox::new(items).dom()` plus the flattened index of every
934
    /// node the handlers navigate to.
935
    struct Fixture {
936
        styled: StyledDom,
937
        wrapper: usize,
938
        field: usize,
939
        text: usize,
940
        list: usize,
941
        options: Vec<usize>,
942
    }
943

            
944
    fn fixture(items: &[&str]) -> Fixture {
945
        let styled = StyledDom::create_from_dom(ComboBox::new(sv(items)).dom());
946

            
947
        fn one(styled: &StyledDom, class: &str) -> usize {
948
            let found = nodes_with_class(styled, class);
949
            assert_eq!(found.len(), 1, "expected exactly one `{class}` node");
950
            found[0]
951
        }
952

            
953
        let wrapper = one(&styled, "__azul-native-combobox");
954
        let field = one(&styled, "__azul-native-combobox-input");
955
        // The class sits on the label `<p>`; the node the handlers re-text is
956
        // the bare text leaf inside it, which pre-order flattening puts next.
957
        let text_box = one(&styled, "__azul-native-combobox-text");
958
        let text = text_box + 1;
959
        assert!(
960
            matches!(styled.node_data.as_ref()[text].get_node_type(), NodeType::Text(_)),
961
            "the combobox field label must be `p > text`"
962
        );
963
        let list = one(&styled, "__azul-native-combobox-list");
964
        let options = nodes_with_class(&styled, "__azul-native-combobox-option");
965
        assert_eq!(options.len(), items.len());
966

            
967
        Fixture {
968
            styled,
969
            wrapper,
970
            field,
971
            text,
972
            list,
973
            options,
974
        }
975
    }
976

            
977
    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
978
    fn node(idx: usize) -> DomNodeId {
979
        DomNodeId {
980
            dom: DomId::ROOT_ID,
981
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
982
        }
983
    }
984

            
985
    /// A `DomLayoutResult` with an *empty* layout tree: these handlers only walk
986
    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
987
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
988
        DomLayoutResult {
989
            styled_dom,
990
            layout_tree: LayoutTree {
991
                nodes: Vec::new(),
992
                warm: Vec::new(),
993
                cold: Vec::new(),
994
                root: 0,
995
                dom_to_layout: BTreeMap::new(),
996
                children_arena: Vec::new(),
997
                children_offsets: Vec::new(),
998
                subtree_needs_intrinsic: Vec::new(),
999
            },
            calculated_positions: Vec::new(),
            viewport: LogicalRect::zero(),
            display_list: Arc::new(DisplayList::default()),
            scroll_ids: HashMap::new(),
            scroll_id_to_node_id: HashMap::new(),
        }
    }
    /// Everything the combobox handlers read out of the window: the styled DOM
    /// they navigate, the pending text changeset, and the pressed key.
    #[derive(Default)]
    struct Env {
        styled: Option<StyledDom>,
        changeset: Option<PendingTextEdit>,
        keycode: Option<VirtualKeyCode>,
    }
    /// Invokes `call` against a `LayoutWindow` built from `env`, with `hit` as the
    /// hit node. Returns the handler's value plus every recorded `CallbackChange`.
    fn run<R>(
        env: Env,
        hit: usize,
        data: RefAny,
        call: impl FnOnce(RefAny, CallbackInfo) -> R,
    ) -> (R, Vec<CallbackChange>) {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        if let Some(sd) = env.styled {
            layout_window
                .layout_results
                .insert(DomId::ROOT_ID, layout_result(sd));
        }
        if let Some(changeset) = env.changeset {
            layout_window.text_input_manager.set_changeset(changeset);
        }
        let renderer_resources = RendererResources::default();
        let previous_window_state: Option<FullWindowState> = None;
        let mut current_window_state = FullWindowState::default();
        current_window_state.keyboard_state.current_virtual_keycode = env.keycode.into();
        let gl_context = OptionGlContextPtr::None;
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
            BTreeMap::new();
        let window_handle = RawWindowHandle::Unsupported;
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
        let ref_data = CallbackInfoRefData {
            layout_window: &layout_window,
            renderer_resources: &renderer_resources,
            previous_window_state: &previous_window_state,
            current_window_state: &current_window_state,
            gl_context: &gl_context,
            current_scroll_manager: &scroll_states,
            current_window_handle: &window_handle,
            system_callbacks: &system_callbacks,
            system_style: Arc::new(SystemStyle::default()),
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
            #[cfg(feature = "icu")]
            icu_localizer: IcuLocalizerHandle::default(),
            ctx: OptionRefAny::None,
        };
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
        let info = CallbackInfo::new(
            &ref_data,
            &changes,
            node(hit),
            OptionLogicalPosition::None,
            OptionLogicalPosition::None,
        );
        let out = call(data, info);
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
        (out, recorded)
    }
    /// Every `display` write in the change log, as `(node index, display)`.
    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
        let mut out = Vec::new();
        for change in changes {
            if let CallbackChange::ChangeNodeCssProperties {
                node_id, properties, ..
            } = change
            {
                for p in properties.as_ref() {
                    if let CssProperty::Display(v) = p {
                        if let Some(d) = v.get_property() {
                            out.push((node_id.index(), *d));
                        }
                    }
                }
            }
        }
        out
    }
    /// Every text write in the change log, as `(node index, new text)`.
    fn text_writes(changes: &[CallbackChange]) -> Vec<(usize, String)> {
        changes
            .iter()
            .filter_map(|change| match change {
                CallbackChange::ChangeNodeText { node_id, text } => Some((
                    node_id
                        .node
                        .into_crate_internal()
                        .expect("a text write always targets a real node")
                        .index(),
                    text.as_str().to_string(),
                )),
                _ => None,
            })
            .collect()
    }
    /// A `ComboBoxStateWrapper` payload with no user callback.
    fn state(items: &[&str], text: &str, open: bool, selected: usize) -> RefAny {
        RefAny::new(ComboBoxStateWrapper {
            inner: ComboBoxState {
                open,
                selected,
                text: AzString::from(text),
            },
            items: sv(items),
            on_select: None.into(),
        })
    }
    /// Reads the (still shared) `ComboBoxState` back out of a payload.
    fn inner_of(data: &mut RefAny) -> ComboBoxState {
        data.downcast_ref::<ComboBoxStateWrapper>()
            .expect("payload must still be a ComboBoxStateWrapper")
            .inner
            .clone()
    }
    fn on_select_cb(f: ComboBoxOnSelectCallbackType) -> ComboBoxOnSelectCallback {
        f.into()
    }
    /// Records every `ComboBoxState` a user `on_select` was handed.
    struct SelectLog {
        calls: Vec<ComboBoxState>,
    }
    extern "C" fn record_select(mut data: RefAny, _: CallbackInfo, s: ComboBoxState) -> Update {
        if let Some(mut log) = data.downcast_mut::<SelectLog>() {
            log.calls.push(s);
        }
        Update::RefreshDom
    }
    extern "C" fn select_do_nothing(_: RefAny, _: CallbackInfo, _: ComboBoxState) -> Update {
        Update::DoNothing
    }
    thread_local! {
        /// A clone of the shared state handle, smuggled into `probe_reborrow`
        /// without building a self-referential `RefAny` cycle.
        static SHARED_ALIAS: RefCell<Option<RefAny>> = const { RefCell::new(None) };
        /// `Some(true)` once `probe_reborrow` has seen the re-borrow refused.
        static REBORROW_REFUSED: Cell<Option<bool>> = const { Cell::new(None) };
    }
    extern "C" fn probe_reborrow(_: RefAny, _: CallbackInfo, _: ComboBoxState) -> Update {
        let refused = SHARED_ALIAS.with(|alias| {
            alias
                .borrow_mut()
                .as_mut()
                .expect("alias installed by the test")
                .downcast_mut::<ComboBoxStateWrapper>()
                .is_none()
        });
        REBORROW_REFUSED.with(|c| c.set(Some(refused)));
        Update::DoNothing
    }
    // ------------------------------------------------------------------
    // build_list_style
    // ------------------------------------------------------------------
    #[test]
    fn build_list_style_differs_only_in_display() {
        let closed = build_list_style(false);
        let open = build_list_style(true);
        let (c, o) = (closed.as_ref(), open.as_ref());
        assert!(!c.is_empty(), "the list style must not be empty");
        assert_eq!(
            c.len(),
            o.len(),
            "open and closed must declare the same property set so the runtime \
             `set_css_property(display)` toggle has everything it needs"
        );
        let differing: Vec<usize> = (0..c.len()).filter(|&i| c[i] != o[i]).collect();
        assert_eq!(
            differing.len(),
            1,
            "exactly one property may differ between open and closed"
        );
        assert!(matches!(
            &c[differing[0]].property,
            CssProperty::Display(_)
        ));
    }
    #[test]
    fn build_list_style_display_follows_the_flag() {
        assert_eq!(
            display_of(&build_list_style(false)),
            Some(LayoutDisplay::None),
            "a closed list is hidden"
        );
        assert_eq!(
            display_of(&build_list_style(true)),
            Some(LayoutDisplay::Block),
            "an open list is shown"
        );
    }
    #[test]
    fn build_list_style_always_positions_absolutely() {
        // Positioning must be present in BOTH states — the toggle only rewrites
        // `display`, so a missing `position` in the closed style would leave the
        // list statically positioned once opened.
        for open in [false, true] {
            let props = build_list_style(open);
            assert_eq!(
                position_of(&props),
                Some(LayoutPosition::Absolute),
                "open={open}"
            );
        }
    }
    #[test]
    fn build_list_style_is_deterministic_and_unshared() {
        // Two calls with the same flag must be equal, and neither may alias the
        // other (it allocates a fresh vec every call).
        assert_eq!(build_list_style(true), build_list_style(true));
        assert_eq!(build_list_style(false), build_list_style(false));
        assert_ne!(build_list_style(true), build_list_style(false));
    }
    // ------------------------------------------------------------------
    // ComboBox::new / create / Default
    // ------------------------------------------------------------------
    #[test]
    fn new_stores_items_and_starts_at_documented_defaults() {
        let combo = ComboBox::new(sv(&["a", "b", "c"]));
        assert_eq!(combo.combo_state.items.as_ref().len(), 3);
        assert_eq!(combo.combo_state.items.as_ref()[2].as_str(), "c");
        assert_eq!(combo.combo_state.inner, ComboBoxState::default());
        assert!(!combo.combo_state.inner.open, "the list starts closed");
        assert_eq!(combo.combo_state.inner.selected, 0);
        assert!(combo.combo_state.inner.text.as_str().is_empty());
        assert!(combo.placeholder.as_str().is_empty());
        assert!(
            combo.combo_state.on_select.is_none(),
            "ComboBox::new sets no callback"
        );
    }
    #[test]
    fn new_survives_extreme_item_lists() {
        let long = "ab".repeat(50_000);
        let cases: Vec<Vec<AzString>> = alloc::vec![
            Vec::new(),
            alloc::vec![AzString::from("")],
            alloc::vec![AzString::from("a\0b"), AzString::from("")],
            alloc::vec![AzString::from(
                "👨‍👩‍👧‍👦 e\u{0301}\u{0327} مرحبا שלום 🇩🇪"
            )],
            alloc::vec![AzString::from("\u{feff}\u{202e}rtl-override")],
            alloc::vec![AzString::from(long.as_str())],
        ];
        for items in cases {
            let combo = ComboBox::new(StringVec::from_vec(items.clone()));
            assert_eq!(combo.combo_state.items.as_ref(), items.as_slice());
            // ...and every option survives the trip through the DOM byte-for-byte
            let dom = combo.dom();
            let (_, list) = parts(&dom);
            assert_eq!(list.children.as_ref().len(), items.len());
            for (i, item) in items.iter().enumerate() {
                assert_eq!(text_of(&list.children.as_ref()[i]), Some(item.as_str()));
            }
        }
    }
    #[test]
    fn new_handles_many_duplicate_items() {
        // Duplicates are legal: selection is by index, not by label.
        let items = sv(&["same"; 512]);
        let combo = ComboBox::new(items);
        assert_eq!(combo.combo_state.items.as_ref().len(), 512);
        let dom = combo.dom();
        let (_, list) = parts(&dom);
        assert_eq!(list.children.as_ref().len(), 512);
    }
    #[test]
    fn create_is_empty_and_equals_default() {
        let combo = ComboBox::create();
        assert!(combo.combo_state.items.as_ref().is_empty());
        assert!(combo.combo_state.on_select.is_none());
        assert_eq!(combo.combo_state.inner, ComboBoxState::default());
        assert_eq!(combo, ComboBox::default());
        // repeated calls are independent, equal values
        assert_eq!(ComboBox::create(), ComboBox::create());
    }
    // ------------------------------------------------------------------
    // set_selected / with_selected  (numeric)
    // ------------------------------------------------------------------
    #[test]
    fn set_selected_stores_every_index_verbatim() {
        // The setter is documented as a plain store: no clamping to items.len(),
        // no saturation, no wrap — assert exactly that at both ends of usize.
        for value in [0usize, 1, 2, usize::MAX - 1, usize::MAX] {
            let mut combo = ComboBox::new(sv(&["a", "b"]));
            combo.set_selected(value);
            assert_eq!(combo.combo_state.inner.selected, value);
            // nothing else moved
            assert!(!combo.combo_state.inner.open);
            assert!(combo.combo_state.inner.text.as_str().is_empty());
            assert_eq!(combo.combo_state.items.as_ref().len(), 2);
        }
    }
    #[test]
    fn set_selected_last_write_wins() {
        let mut combo = ComboBox::create();
        combo.set_selected(usize::MAX);
        combo.set_selected(0);
        assert_eq!(combo.combo_state.inner.selected, 0);
        combo.set_selected(7);
        combo.set_selected(7);
        assert_eq!(combo.combo_state.inner.selected, 7, "idempotent re-set");
    }
    #[test]
    fn with_selected_matches_set_selected() {
        for value in [0usize, 3, usize::MAX] {
            let built = ComboBox::new(sv(&["a"])).with_selected(value);
            let mut mutated = ComboBox::new(sv(&["a"]));
            mutated.set_selected(value);
            assert_eq!(built, mutated);
        }
    }
    #[test]
    fn out_of_range_selected_still_renders_without_panicking() {
        // `dom()` never indexes `items` by `selected`, so an out-of-range index
        // (including usize::MAX on an EMPTY item list) must render fine.
        for (items, selected) in [
            (alloc::vec![], usize::MAX),
            (alloc::vec!["a"], 99),
            (alloc::vec!["a", "b"], usize::MAX - 1),
        ] {
            let combo = ComboBox::new(sv(&items)).with_selected(selected);
            assert_eq!(combo.combo_state.inner.selected, selected);
            let dom = combo.dom();
            let (_, list) = parts(&dom);
            assert_eq!(list.children.as_ref().len(), items.len());
        }
    }
    // ------------------------------------------------------------------
    // set_text / with_text
    // ------------------------------------------------------------------
    #[test]
    fn set_text_stores_every_string_verbatim() {
        let long = "x".repeat(100_000);
        let cases = [
            "",
            " ",
            "a\0b",
            "line\nbreak\ttab",
            "👨‍👩‍👧‍👦 e\u{0301}\u{0327} مرحبا שלום 🇩🇪",
            "\u{feff}\u{202e}rtl",
            long.as_str(),
        ];
        for case in cases {
            let mut combo = ComboBox::create();
            combo.set_text(AzString::from(case));
            assert_eq!(combo.combo_state.inner.text.as_str(), case);
            assert_eq!(
                combo.combo_state.inner.text.as_str().len(),
                case.len(),
                "no truncation at the NUL or anywhere else"
            );
        }
    }
    #[test]
    fn with_text_matches_set_text_and_last_write_wins() {
        let built = ComboBox::create().with_text("a".into()).with_text("b".into());
        let mut mutated = ComboBox::create();
        mutated.set_text("a".into());
        mutated.set_text("b".into());
        assert_eq!(built, mutated);
        assert_eq!(built.combo_state.inner.text.as_str(), "b");
    }
    // ------------------------------------------------------------------
    // set_placeholder / with_placeholder
    // ------------------------------------------------------------------
    #[test]
    fn set_placeholder_stores_verbatim_and_does_not_touch_text() {
        let mut combo = ComboBox::new(sv(&["a"]));
        combo.set_placeholder("Pick one…\u{0}".into());
        assert_eq!(combo.placeholder.as_str(), "Pick one…\u{0}");
        assert!(
            combo.combo_state.inner.text.as_str().is_empty(),
            "the placeholder is not the value"
        );
        let built = ComboBox::new(sv(&["a"])).with_placeholder("Pick one…\u{0}".into());
        assert_eq!(built, combo);
    }
    #[test]
    fn placeholder_is_the_field_label_only_while_text_is_empty() {
        // Documented simplification: there is no separate placeholder node, so the
        // field label is `text` if non-empty, else `placeholder`.
        let dom = ComboBox::create().with_placeholder("ph".into()).dom();
        let (field, _) = parts(&dom);
        assert_eq!(text_of(&field.children.as_ref()[0]), Some("ph"));
        // a single SPACE is non-empty, so it must win over the placeholder
        let spaced = ComboBox::create()
            .with_placeholder("ph".into())
            .with_text(" ".into());
        let dom = spaced.dom();
        let (field, _) = parts(&dom);
        assert_eq!(text_of(&field.children.as_ref()[0]), Some(" "));
        // ...and with no placeholder and no text the label is the empty string
        let bare = ComboBox::create().dom();
        let (field, _) = parts(&bare);
        assert_eq!(text_of(&field.children.as_ref()[0]), Some(""));
    }
    // ------------------------------------------------------------------
    // set_on_select / with_on_select
    // ------------------------------------------------------------------
    #[test]
    fn set_on_select_last_call_wins() {
        let mut combo = ComboBox::create();
        combo.set_on_select(RefAny::new(1u8), on_select_cb(select_do_nothing));
        assert!(combo.combo_state.on_select.is_some());
        // a second call must *replace* (not append / leak / panic)
        combo.set_on_select(RefAny::new(9i64), on_select_cb(record_select));
        let set = combo.combo_state.on_select.as_ref().expect("still Some");
        assert_eq!(set.refany.get_type_id(), RefAny::new(0i64).get_type_id());
        assert_eq!(set.callback, on_select_cb(record_select));
        assert_ne!(set.callback, on_select_cb(select_do_nothing));
    }
    #[test]
    fn with_on_select_matches_set_on_select() {
        let built = ComboBox::new(sv(&["a"]))
            .with_on_select(RefAny::new(7u32), on_select_cb(record_select));
        let mut mutated = ComboBox::new(sv(&["a"]));
        mutated.set_on_select(RefAny::new(7u32), on_select_cb(record_select));
        assert_eq!(
            built.combo_state.on_select.as_ref().unwrap().callback,
            mutated.combo_state.on_select.as_ref().unwrap().callback
        );
        // the builder form must not disturb the items or the state
        assert_eq!(built.combo_state.items.as_ref().len(), 1);
        assert_eq!(built.combo_state.inner, ComboBoxState::default());
    }
    // ------------------------------------------------------------------
    // swap_with_default
    // ------------------------------------------------------------------
    #[test]
    fn swap_with_default_moves_all_state_out() {
        let mut combo = ComboBox::new(sv(&["a", "b"]))
            .with_selected(1)
            .with_text("typed".into())
            .with_placeholder("ph".into())
            .with_on_select(RefAny::new(5u8), on_select_cb(record_select));
        let original = combo.swap_with_default();
        assert_eq!(original.combo_state.items.as_ref().len(), 2);
        assert_eq!(original.combo_state.inner.selected, 1);
        assert_eq!(original.combo_state.inner.text.as_str(), "typed");
        assert_eq!(original.placeholder.as_str(), "ph");
        assert!(original.combo_state.on_select.is_some());
        assert_eq!(combo, ComboBox::create(), "self must be left empty");
        // swapping an already-empty combobox is a no-op, not a panic
        let second = combo.swap_with_default();
        assert_eq!(second, ComboBox::create());
        assert_eq!(combo, ComboBox::create());
    }
    // ------------------------------------------------------------------
    // ComboBox::dom
    // ------------------------------------------------------------------
    #[test]
    fn dom_of_empty_combobox_still_has_field_and_empty_list() {
        let dom = ComboBox::create().dom();
        assert!(has_class(&dom, "__azul-native-combobox"));
        let (field, list) = parts(&dom);
        assert!(has_class(field, "__azul-native-combobox-input"));
        assert!(has_class(list, "__azul-native-combobox-list"));
        assert!(
            list.children.as_ref().is_empty(),
            "no items -> no option rows"
        );
        // the field still has its text node + arrow
        assert_eq!(field.children.as_ref().len(), 2);
    }
    #[test]
    fn dom_structure_classes_and_callbacks() {
        let dom = ComboBox::new(sv(&["one", "two"])).dom();
        let (field, list) = parts(&dom);
        let text_node = &field.children.as_ref()[0];
        let arrow = &field.children.as_ref()[1];
        assert!(has_class(text_node, "__azul-native-combobox-text"));
        assert!(has_class(arrow, "__azul-native-combobox-arrow"));
        assert_eq!(icon_of(arrow), Some("arrow_drop_down"));
        // the field is focusable and wires exactly toggle / text-input / key-down
        assert!(matches!(field.root.get_tab_index(), Some(TabIndex::Auto)));
        let cbs = field.root.get_callbacks();
        assert_eq!(cbs.len(), 3);
        assert_eq!(
            cbs.as_ref()[0].event,
            EventFilter::Hover(HoverEventFilter::MouseUp)
        );
        assert_eq!(cbs.as_ref()[0].callback.cb, on_combobox_toggle as usize);
        assert_eq!(
            cbs.as_ref()[1].event,
            EventFilter::Focus(FocusEventFilter::TextInput)
        );
        assert_eq!(cbs.as_ref()[1].callback.cb, on_combobox_text_input as usize);
        assert_eq!(
            cbs.as_ref()[2].event,
            EventFilter::Focus(FocusEventFilter::VirtualKeyDown)
        );
        assert_eq!(cbs.as_ref()[2].callback.cb, on_combobox_key_down as usize);
        // every option is focusable and carries exactly one click handler
        for (i, option) in list.children.as_ref().iter().enumerate() {
            assert!(has_class(option, "__azul-native-combobox-option"));
            assert_eq!(text_of(option), Some(["one", "two"][i]));
            assert!(matches!(option.root.get_tab_index(), Some(TabIndex::Auto)));
            let cbs = option.root.get_callbacks();
            assert_eq!(cbs.len(), 1);
            assert_eq!(
                cbs.as_ref()[0].event,
                EventFilter::Hover(HoverEventFilter::MouseUp)
            );
            assert_eq!(
                cbs.as_ref()[0].callback.cb,
                on_combobox_option_click as usize
            );
        }
    }
    #[test]
    fn dom_list_display_follows_open() {
        let closed = ComboBox::new(sv(&["a"])).dom();
        assert_eq!(inline_display(parts(&closed).1), Some(LayoutDisplay::None));
        let mut open = ComboBox::new(sv(&["a"]));
        open.combo_state.inner.open = true;
        let open = open.dom();
        assert_eq!(inline_display(parts(&open).1), Some(LayoutDisplay::Block));
    }
    #[test]
    fn dom_shares_exactly_one_refany_across_every_callback() {
        // The module doc promises ONE shared RefAny: a write through the field's
        // handle must be visible through every option's handle.
        let dom = ComboBox::new(sv(&["a", "b", "c"])).dom();
        let (field, list) = parts(&dom);
        let field_refany = &field.root.get_callbacks().as_ref()[0].refany;
        for cb in field.root.get_callbacks().as_ref() {
            assert_eq!(&cb.refany, field_refany, "field handlers share one state");
        }
        for option in list.children.as_ref() {
            assert_eq!(
                &option.root.get_callbacks().as_ref()[0].refany,
                field_refany,
                "option handlers share the field's state"
            );
        }
        // ...and it is actually the same allocation, not just an equal one
        let mut writer = field_refany.clone();
        {
            let mut w = writer
                .downcast_mut::<ComboBoxStateWrapper>()
                .expect("the shared payload is a ComboBoxStateWrapper");
            w.inner.selected = 2;
            w.inner.open = true;
        }
        let mut reader = list.children.as_ref()[0].root.get_callbacks().as_ref()[0]
            .refany
            .clone();
        let seen = inner_of(&mut reader);
        assert_eq!(seen.selected, 2);
        assert!(seen.open);
    }
    #[test]
    fn dom_round_trips_items_and_state_into_the_shared_payload() {
        let combo = ComboBox::new(sv(&["α", "β", "\0"]))
            .with_selected(2)
            .with_text("typed".into());
        let expected = combo.combo_state.clone();
        let dom = combo.dom();
        let mut shared = parts(&dom).0.root.get_callbacks().as_ref()[0]
            .refany
            .clone();
        let decoded = shared
            .downcast_ref::<ComboBoxStateWrapper>()
            .expect("payload type is preserved");
        assert_eq!(decoded.inner, expected.inner);
        assert_eq!(decoded.items.as_ref(), expected.items.as_ref());
        assert!(decoded.on_select.is_none());
    }
    #[test]
    fn dom_child_count_cache_stays_consistent() {
        // A wrong `estimated_total_children` under-allocates the compact-DOM
        // arena and panics much later.
        for items in [
            alloc::vec![],
            alloc::vec!["a"],
            alloc::vec!["a", "", "\u{1F600}"],
        ] {
            let dom = ComboBox::new(sv(&items))
                .with_placeholder("ph".into())
                .dom();
            assert_eq!(
                dom.estimated_total_children,
                dom.recompute_estimated_total_children(),
                "cached descendant count desynced for {} item(s)",
                items.len()
            );
        }
    }
    #[test]
    fn from_combobox_for_dom_renders_the_same_tree() {
        // `Dom::from` delegates to `dom()`; the trees are structurally identical
        // (they are NOT `==`, because each render mints a fresh shared `RefAny`).
        let combo = ComboBox::new(sv(&["a", "b"])).with_text("t".into());
        let via_from = Dom::from(combo.clone());
        let via_dom = combo.dom();
        assert_eq!(
            via_from.estimated_total_children,
            via_dom.estimated_total_children
        );
        let (ff, fl) = parts(&via_from);
        let (df, dl) = parts(&via_dom);
        assert_eq!(text_of(&ff.children.as_ref()[0]), text_of(&df.children.as_ref()[0]));
        assert_eq!(inline_display(fl), inline_display(dl));
        assert_eq!(fl.children.as_ref().len(), dl.children.as_ref().len());
        assert_ne!(
            ff.root.get_callbacks().as_ref()[0].refany,
            df.root.get_callbacks().as_ref()[0].refany,
            "each render owns its own state allocation"
        );
    }
    // ------------------------------------------------------------------
    // on_combobox_toggle
    // ------------------------------------------------------------------
    #[test]
    fn toggle_without_any_layout_result_is_a_noop() {
        let mut data = state(&["a"], "", false, 0);
        let (update, changes) = run(Env::default(), 0, data.clone(), |r, ci| on_combobox_toggle(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(!inner_of(&mut data).open, "state must not flip");
    }
    #[test]
    fn toggle_with_a_stale_hit_node_is_a_noop() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            9_999,
            data.clone(),
            |r, ci| on_combobox_toggle(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(!inner_of(&mut data).open);
    }
    #[test]
    fn toggle_on_a_node_without_a_next_sibling_does_not_flip_state() {
        // The list is the wrapper's LAST child: hitting it finds no sibling, and
        // crucially `open` must NOT have been toggled on the way out.
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "", true, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.list,
            data.clone(),
            |r, ci| on_combobox_toggle(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(inner_of(&mut data).open, "state must be untouched");
    }
    #[test]
    fn toggle_with_a_foreign_payload_does_not_restyle() {
        let fx = fixture(&["a"]);
        let data = RefAny::new(0xdead_beef_u64);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.field,
            data,
            |r, ci| on_combobox_toggle(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "a foreign payload must not show or hide the list"
        );
    }
    #[test]
    fn toggle_flips_open_and_shows_then_hides_the_list() {
        let fx = fixture(&["a", "b"]);
        let mut data = state(&["a", "b"], "", false, 0);
        // closed -> open
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled.clone()),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_toggle(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(fx.list, LayoutDisplay::Block)],
            "the field's next sibling (the list) is the node that is shown"
        );
        assert!(inner_of(&mut data).open);
        // open -> closed (same payload, so the flip must be stateful)
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_toggle(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(fx.list, LayoutDisplay::None)]
        );
        assert!(!inner_of(&mut data).open);
    }
    // ------------------------------------------------------------------
    // on_combobox_text_input / on_combobox_text_input_inner
    // ------------------------------------------------------------------
    #[test]
    fn text_input_without_a_changeset_is_a_noop() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "abc", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(inner_of(&mut data).text.as_str(), "abc", "text untouched");
    }
    #[test]
    fn text_input_with_an_empty_insertion_is_a_noop() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "abc", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    node: node(fx.text),
                    inserted_text: AzString::from(""),
                    old_text: AzString::from("abc"),
                }),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "an empty insertion must not re-text the node"
        );
        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
    }
    #[test]
    fn text_input_on_a_childless_node_is_a_noop() {
        // A bare text leaf has no children of its own: `get_first_child`
        // returns None before any state is touched.
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "abc", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    node: node(fx.text),
                    inserted_text: AzString::from("z"),
                    old_text: AzString::from("abc"),
                }),
                ..Env::default()
            },
            fx.text,
            data.clone(),
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
    }
    #[test]
    fn text_input_appends_to_state_and_retexts_the_field() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "ab", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    node: node(fx.text),
                    inserted_text: AzString::from("c"),
                    old_text: AzString::from("ab"),
                }),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            text_writes(&changes),
            alloc::vec![(fx.text, String::from("abc"))],
            "the text leaf inside the field's label <p> is the node that is re-texted"
        );
        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
        // selection/open state is not disturbed by typing
        assert!(!inner_of(&mut data).open);
        assert_eq!(inner_of(&mut data).selected, 0);
    }
    #[test]
    fn text_input_accumulates_across_keystrokes() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "", false, 0);
        for (i, ch) in ["h", "é", "🌍", "\0"].iter().enumerate() {
            let (update, changes) = run(
                Env {
                    styled: Some(fx.styled.clone()),
                    changeset: Some(PendingTextEdit {
                        node: node(fx.text),
                        inserted_text: AzString::from(*ch),
                        old_text: AzString::from(""),
                    }),
                    ..Env::default()
                },
                fx.field,
                data.clone(),
                |r, ci| on_combobox_text_input(r, ci),
            );
            assert_eq!(update, Update::DoNothing);
            assert_eq!(changes.len(), 1, "keystroke {i} produced one text write");
        }
        assert_eq!(inner_of(&mut data).text.as_str(), "hé🌍\0");
    }
    #[test]
    fn text_input_ignores_the_changesets_own_target_node() {
        // Quirk worth pinning: the handler re-texts the HIT node's first child and
        // never looks at `changeset.node`. A changeset naming a nonexistent node
        // is applied to the field anyway (rather than being dropped or panicking).
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    // usize::MAX - 1 is the largest index the 1-based
                    // `NodeHierarchyItemId` encoding accepts without overflowing.
                    node: node(usize::MAX - 1),
                    inserted_text: AzString::from("q"),
                    old_text: AzString::from("ignored"),
                }),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(text_writes(&changes), alloc::vec![(fx.text, String::from("q"))]);
        assert_eq!(
            inner_of(&mut data).text.as_str(),
            "q",
            "`old_text` is ignored: the append is against the widget's own state"
        );
    }
    #[test]
    fn text_input_with_a_foreign_payload_leaves_the_dom_untouched() {
        let fx = fixture(&["a"]);
        let data = RefAny::new("not a combobox");
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    node: node(fx.text),
                    inserted_text: AzString::from("x"),
                    old_text: AzString::from(""),
                }),
                ..Env::default()
            },
            fx.field,
            data,
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn text_input_survives_a_huge_insertion() {
        let fx = fixture(&["a"]);
        let huge = "y".repeat(100_000);
        let mut data = state(&["a"], "", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    node: node(fx.text),
                    inserted_text: AzString::from(huge.as_str()),
                    old_text: AzString::from(""),
                }),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_text_input(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(changes.len(), 1);
        assert_eq!(inner_of(&mut data).text.as_str().len(), 100_000);
    }
    #[test]
    fn text_input_inner_reports_none_when_it_does_nothing() {
        // The `_inner` half distinguishes "nothing to do" (None) from "handled"
        // (Some) — the extern wrapper collapses both to DoNothing.
        let fx = fixture(&["a"]);
        let (out, _) = run(
            Env {
                styled: Some(fx.styled.clone()),
                ..Env::default()
            },
            fx.field,
            state(&["a"], "", false, 0),
            on_combobox_text_input_inner,
        );
        assert_eq!(out, None, "no changeset -> None");
        let (out, _) = run(
            Env {
                styled: Some(fx.styled),
                changeset: Some(PendingTextEdit {
                    node: node(fx.text),
                    inserted_text: AzString::from("k"),
                    old_text: AzString::from(""),
                }),
                ..Env::default()
            },
            fx.field,
            state(&["a"], "", false, 0),
            on_combobox_text_input_inner,
        );
        assert_eq!(out, Some(Update::DoNothing), "handled -> Some");
    }
    // ------------------------------------------------------------------
    // on_combobox_key_down / on_combobox_key_down_inner
    // ------------------------------------------------------------------
    #[test]
    fn key_down_without_a_keycode_is_a_noop() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "abc", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
    }
    #[test]
    fn key_down_ignores_every_key_except_backspace() {
        let fx = fixture(&["a"]);
        for key in [
            VirtualKeyCode::A,
            VirtualKeyCode::Return,
            VirtualKeyCode::Escape,
            VirtualKeyCode::Delete,
            VirtualKeyCode::Space,
        ] {
            let mut data = state(&["a"], "abc", false, 0);
            let (update, changes) = run(
                Env {
                    styled: Some(fx.styled.clone()),
                    keycode: Some(key),
                    ..Env::default()
                },
                fx.field,
                data.clone(),
                |r, ci| on_combobox_key_down(r, ci),
            );
            assert_eq!(update, Update::DoNothing);
            assert!(changes.is_empty(), "{key:?} must not edit the text");
            assert_eq!(inner_of(&mut data).text.as_str(), "abc");
        }
    }
    #[test]
    fn key_down_backspace_pops_one_char_not_one_byte() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "hé🌍", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled.clone()),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            text_writes(&changes),
            alloc::vec![(fx.text, String::from("hé"))],
            "the 4-byte 🌍 is removed whole — no UTF-8 boundary panic"
        );
        assert_eq!(inner_of(&mut data).text.as_str(), "hé");
        // the two-byte é goes next, still whole
        let (_, changes) = run(
            Env {
                styled: Some(fx.styled),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(text_writes(&changes), alloc::vec![(fx.text, String::from("h"))]);
        assert_eq!(inner_of(&mut data).text.as_str(), "h");
    }
    #[test]
    fn key_down_backspace_deletes_by_codepoint_not_by_grapheme() {
        // Documented consequence of `String::pop`: a combining mark and a ZWJ
        // emoji sequence lose ONE codepoint per press, not the whole cluster.
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "e\u{0301}", false, 0);
        let (_, changes) = run(
            Env {
                styled: Some(fx.styled.clone()),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(text_writes(&changes), alloc::vec![(fx.text, String::from("e"))]);
        assert_eq!(inner_of(&mut data).text.as_str(), "e");
        // Expected value is derived, not spelled out: the ZWJ joiners the family
        // sequence is built from are invisible in source.
        let family_str = "👨‍👩‍👧";
        let all_but_last: String = {
            let mut s = String::from(family_str);
            s.pop();
            s
        };
        assert_eq!(
            family_str.chars().count(),
            5,
            "man ZWJ woman ZWJ girl — 5 codepoints, 1 grapheme"
        );
        let mut family = state(&["a"], family_str, false, 0);
        let (_, _) = run(
            Env {
                styled: Some(fx.styled),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            family.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        let after = inner_of(&mut family).text.as_str().to_string();
        assert_eq!(
            after, all_but_last,
            "only the trailing codepoint is dropped, not the whole cluster"
        );
        assert_eq!(
            after.chars().count(),
            4,
            "the cluster is still visually broken — one press removed one codepoint"
        );
    }
    #[test]
    fn key_down_backspace_on_empty_text_is_safe() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            data.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            text_writes(&changes),
            alloc::vec![(fx.text, String::new())],
            "popping an empty string is a no-op write, not a panic"
        );
        assert!(inner_of(&mut data).text.as_str().is_empty());
    }
    #[test]
    fn key_down_on_a_childless_node_is_a_noop() {
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "abc", false, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.options[0],
            data.clone(),
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
    }
    #[test]
    fn key_down_with_a_foreign_payload_is_a_noop() {
        let fx = fixture(&["a"]);
        let data = RefAny::new(7u16);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            data,
            |r, ci| on_combobox_key_down(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn key_down_inner_reports_none_when_it_does_nothing() {
        let fx = fixture(&["a"]);
        let (out, _) = run(
            Env {
                styled: Some(fx.styled.clone()),
                keycode: Some(VirtualKeyCode::A),
                ..Env::default()
            },
            fx.field,
            state(&["a"], "abc", false, 0),
            on_combobox_key_down_inner,
        );
        assert_eq!(out, None, "a non-backspace key -> None");
        let (out, _) = run(
            Env {
                styled: Some(fx.styled),
                keycode: Some(VirtualKeyCode::Back),
                ..Env::default()
            },
            fx.field,
            state(&["a"], "abc", false, 0),
            on_combobox_key_down_inner,
        );
        assert_eq!(out, Some(Update::DoNothing), "backspace -> Some");
    }
    // ------------------------------------------------------------------
    // on_combobox_option_click
    // ------------------------------------------------------------------
    #[test]
    fn option_click_without_any_layout_result_is_a_noop() {
        let mut data = state(&["a"], "", true, 0);
        let (update, changes) = run(Env::default(), 0, data.clone(), |r, ci| on_combobox_option_click(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(inner_of(&mut data).open, "state must not change");
    }
    #[test]
    fn option_click_on_a_parentless_node_is_a_noop() {
        // The wrapper is the root: it has no parent, so the walk bails out.
        let fx = fixture(&["a"]);
        let mut data = state(&["a"], "", true, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.wrapper,
            data.clone(),
            |r, ci| on_combobox_option_click(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(inner_of(&mut data).open);
    }
    #[test]
    fn option_click_selects_by_previous_sibling_count() {
        let labels = ["zero", "one", "two", "three"];
        let fx = fixture(&labels);
        for (i, label) in labels.iter().enumerate() {
            let mut data = state(&labels, "", true, 999);
            let (update, changes) = run(
                Env {
                    styled: Some(fx.styled.clone()),
                    ..Env::default()
                },
                fx.options[i],
                data.clone(),
                |r, ci| on_combobox_option_click(r, ci),
            );
            assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
            let inner = inner_of(&mut data);
            assert_eq!(inner.selected, i, "index = number of previous siblings");
            assert_eq!(inner.text.as_str(), *label, "the field takes the label");
            assert!(!inner.open, "selecting closes the list");
            assert_eq!(
                text_writes(&changes),
                alloc::vec![(fx.text, String::from(*label))]
            );
            assert_eq!(
                display_writes(&changes),
                alloc::vec![(fx.list, LayoutDisplay::None)]
            );
        }
    }
    #[test]
    fn option_click_index_walk_scales_to_a_long_list() {
        // The index is derived by walking previous siblings one at a time; make
        // sure a long list terminates and lands on the right (last) index.
        let labels: Vec<String> = (0..200).map(|i| alloc::format!("item{i}")).collect();
        let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
        let fx = fixture(&refs);
        let mut data = state(&refs, "", true, 0);
        let (update, _) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.options[199],
            data.clone(),
            |r, ci| on_combobox_option_click(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        let inner = inner_of(&mut data);
        assert_eq!(inner.selected, 199);
        assert_eq!(inner.text.as_str(), "item199");
    }
    #[test]
    fn option_click_with_an_out_of_range_index_changes_nothing() {
        // The rendered list has 3 rows but the payload only knows 1 item — the
        // `items.get(index)` miss must abort BEFORE any state or DOM write.
        let fx = fixture(&["a", "b", "c"]);
        let mut data = state(&["only"], "keep", true, 42);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.options[2],
            data.clone(),
            |r, ci| on_combobox_option_click(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "no partial write may escape");
        let inner = inner_of(&mut data);
        assert_eq!(inner.selected, 42, "selected must not move");
        assert_eq!(inner.text.as_str(), "keep");
        assert!(inner.open, "the list must not be closed either");
    }
    #[test]
    fn option_click_with_an_empty_item_list_changes_nothing() {
        // Same miss, taken from the other side: a DOM with rows, a payload with
        // no items at all.
        let fx = fixture(&["a"]);
        let mut data = state(&[], "keep", true, 0);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.options[0],
            data.clone(),
            |r, ci| on_combobox_option_click(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(inner_of(&mut data).text.as_str(), "keep");
    }
    #[test]
    fn option_click_with_a_foreign_payload_is_a_noop() {
        let fx = fixture(&["a"]);
        let data = RefAny::new(1u8);
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.options[0],
            data,
            |r, ci| on_combobox_option_click(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn option_click_selects_labels_with_nul_and_emoji_verbatim() {
        let labels = ["a\0b", "👨‍👩‍👧‍👦", ""];
        let fx = fixture(&labels);
        for (i, label) in labels.iter().enumerate() {
            let mut data = state(&labels, "", true, 0);
            let (_, changes) = run(
                Env {
                    styled: Some(fx.styled.clone()),
                    ..Env::default()
                },
                fx.options[i],
                data.clone(),
                |r, ci| on_combobox_option_click(r, ci),
            );
            assert_eq!(inner_of(&mut data).text.as_str(), *label);
            assert_eq!(
                text_writes(&changes),
                alloc::vec![(fx.text, String::from(*label))]
            );
        }
    }
    #[test]
    fn option_click_invokes_the_user_callback_and_propagates_its_update() {
        let fx = fixture(&["a", "b"]);
        let mut log = RefAny::new(SelectLog { calls: Vec::new() });
        let data = RefAny::new(ComboBoxStateWrapper {
            inner: ComboBoxState {
                open: true,
                selected: 0,
                text: AzString::from(""),
            },
            items: sv(&["a", "b"]),
            on_select: Some(ComboBoxOnSelect {
                callback: on_select_cb(record_select),
                refany: log.clone(),
            })
            .into(),
        });
        let (update, changes) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.options[1],
            data,
            |r, ci| on_combobox_option_click(r, ci),
        );
        // the user's return value wins over the internal DoNothing
        assert_eq!(update, Update::RefreshDom);
        // ...and the field/list are still updated, even though the user ran
        assert_eq!(
            text_writes(&changes),
            alloc::vec![(fx.text, String::from("b"))]
        );
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(fx.list, LayoutDisplay::None)]
        );
        let logged = log
            .downcast_ref::<SelectLog>()
            .expect("log payload survived");
        assert_eq!(logged.calls.len(), 1);
        assert_eq!(logged.calls[0].selected, 1, "the callback sees the NEW index");
        assert_eq!(logged.calls[0].text.as_str(), "b", "...and the NEW text");
        assert!(!logged.calls[0].open, "...and an already-closed list");
    }
    #[test]
    fn option_click_holds_the_state_borrow_across_the_user_callback() {
        // The handler invokes `on_select` while its own `downcast_mut` guard is
        // still alive, so a re-entrant borrow of the shared state from inside the
        // user callback is REFUSED (returns None) rather than aliasing or
        // deadlocking. Pinning this documents the constraint on user callbacks.
        let fx = fixture(&["a"]);
        let data = RefAny::new(ComboBoxStateWrapper {
            inner: ComboBoxState::default(),
            items: sv(&["a"]),
            on_select: Some(ComboBoxOnSelect {
                callback: on_select_cb(probe_reborrow),
                refany: RefAny::new(0u8),
            })
            .into(),
        });
        SHARED_ALIAS.with(|a| *a.borrow_mut() = Some(data.clone()));
        REBORROW_REFUSED.with(|c| c.set(None));
        let (update, _) = run(
            Env {
                styled: Some(fx.styled),
                ..Env::default()
            },
            fx.options[0],
            data,
            |r, ci| on_combobox_option_click(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            REBORROW_REFUSED.with(|c| c.get()),
            Some(true),
            "a re-entrant downcast_mut must fail cleanly, not alias or hang"
        );
        SHARED_ALIAS.with(|a| *a.borrow_mut() = None);
    }
}