Skip to main content

azul_layout/widgets/
combobox.rs

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
44use alloc::{string::String, vec::Vec};
45
46use 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};
55use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
56use 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
66use crate::callbacks::{Callback, CallbackInfo};
67
68static COMBOBOX_WRAPPER_CLASS: &[IdOrClass] =
69    &[Class(AzString::from_const_str("__azul-native-combobox"))];
70static COMBOBOX_INPUT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
71    "__azul-native-combobox-input",
72))];
73static COMBOBOX_TEXT_CLASS: &[IdOrClass] =
74    &[Class(AzString::from_const_str("__azul-native-combobox-text"))];
75static COMBOBOX_ARROW_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
76    "__azul-native-combobox-arrow",
77))];
78static COMBOBOX_LIST_CLASS: &[IdOrClass] =
79    &[Class(AzString::from_const_str("__azul-native-combobox-list"))];
80static COMBOBOX_OPTION_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
81    "__azul-native-combobox-option",
82))];
83
84const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
85const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
86const 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).
92const LIST_OFFSET_Y: isize = 28;
93/// Minimum width of the field and the list.
94const MIN_WIDTH: isize = 160;
95const RADIUS: isize = 4;
96const ARROW_FONT_SIZE_PX: isize = 18;
97
98// ---- colours ----
99const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
100const BORDER_COLOR: ColorU = ColorU { r: 172, g: 172, b: 172, a: 255 }; // #acacac
101const BORDER_FOCUS: ColorU = ColorU { r: 66, g: 134, b: 244, a: 255 }; // #4286f4
102const TEXT_COLOR: ColorU = ColorU { r: 51, g: 51, b: 51, a: 255 }; // #333333
103const OPTION_HOVER_BG: ColorU = ColorU { r: 234, g: 244, b: 252, a: 255 }; // #eaf4fc
104
105const WHITE_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(WHITE)];
106const WHITE_BG_VEC: StyleBackgroundContentVec =
107    StyleBackgroundContentVec::from_const_slice(WHITE_BG_ITEMS);
108const OPTION_HOVER_BG_ITEMS: &[StyleBackgroundContent] =
109    &[StyleBackgroundContent::Color(OPTION_HOVER_BG)];
110const 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).
115pub type ComboBoxOnSelectCallbackType = extern "C" fn(RefAny, CallbackInfo, ComboBoxState) -> Update;
116impl_widget_callback!(
117    ComboBoxOnSelect,
118    OptionComboBoxOnSelect,
119    ComboBoxOnSelectCallback,
120    ComboBoxOnSelectCallbackType
121);
122
123azul_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)]
140pub 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}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149#[repr(C)]
150pub struct ComboBoxStateWrapper {
151    /// The mutable per-interaction state passed to `on_select`.
152    pub inner: ComboBoxState,
153    /// The full set of selectable options (rendered into the list).
154    pub items: StringVec,
155    /// Optional: function to call when an option is selected.
156    pub on_select: OptionComboBoxOnSelect,
157}
158
159impl Default for ComboBoxStateWrapper {
160    fn default() -> Self {
161        Self {
162            inner: ComboBoxState::default(),
163            items: StringVec::from_const_slice(&[]),
164            on_select: None.into(),
165        }
166    }
167}
168
169/// The live state of a [`ComboBox`]: whether the list is open, the currently
170/// selected index, and the current (editable) field text.
171#[derive(Debug, Clone, PartialEq, Eq)]
172#[repr(C)]
173pub struct ComboBoxState {
174    /// `true` = list shown, `false` (default) = list hidden.
175    pub open: bool,
176    /// Zero-based index of the most recently selected option.
177    pub selected: usize,
178    /// The current text shown in the field (typed or set from a selection).
179    pub text: AzString,
180}
181
182impl Default for ComboBoxState {
183    fn default() -> Self {
184        Self {
185            open: false,
186            selected: 0,
187            text: AzString::from_const_str(""),
188        }
189    }
190}
191
192// ---- styles ----
193
194/// Wrapper: an inline-block positioning context so the absolutely-positioned list
195/// is placed relative to it.
196static COMBOBOX_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
197    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
198    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
199    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
200    CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
201        MIN_WIDTH,
202    ))),
203    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
204    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
205];
206
207/// The clickable, focusable, editable input field (text + arrow).
208static COMBOBOX_INPUT_STYLE: &[CssPropertyWithConditions] = &[
209    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
210    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
211    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
212    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
213    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
214    // padding: 3px 4px
215    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(3))),
216    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
217        LayoutPaddingBottom::const_px(3),
218    )),
219    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
220        4,
221    ))),
222    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
223        LayoutPaddingRight::const_px(4),
224    )),
225    // border: 1px solid #acacac
226    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
227        LayoutBorderTopWidth::const_px(1),
228    )),
229    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
230        LayoutBorderBottomWidth::const_px(1),
231    )),
232    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
233        LayoutBorderLeftWidth::const_px(1),
234    )),
235    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
236        LayoutBorderRightWidth::const_px(1),
237    )),
238    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
239        inner: BorderStyle::Solid,
240    })),
241    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
242        StyleBorderBottomStyle {
243            inner: BorderStyle::Solid,
244        },
245    )),
246    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
247        inner: BorderStyle::Solid,
248    })),
249    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
250        StyleBorderRightStyle {
251            inner: BorderStyle::Solid,
252        },
253    )),
254    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
255        inner: BORDER_COLOR,
256    })),
257    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
258        StyleBorderBottomColor {
259            inner: BORDER_COLOR,
260        },
261    )),
262    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
263        inner: BORDER_COLOR,
264    })),
265    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
266        StyleBorderRightColor {
267            inner: BORDER_COLOR,
268        },
269    )),
270    // border-radius: 4px
271    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
272        StyleBorderTopLeftRadius::const_px(RADIUS),
273    )),
274    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
275        StyleBorderTopRightRadius::const_px(RADIUS),
276    )),
277    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
278        StyleBorderBottomLeftRadius::const_px(RADIUS),
279    )),
280    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
281        StyleBorderBottomRightRadius::const_px(RADIUS),
282    )),
283    CssPropertyWithConditions::simple(CssProperty::const_background_content(WHITE_BG_VEC)),
284    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
285        inner: TEXT_COLOR,
286    })),
287    // focus: highlight border
288    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
289        inner: BORDER_FOCUS,
290    })),
291    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
292        StyleBorderBottomColor {
293            inner: BORDER_FOCUS,
294        },
295    )),
296    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(StyleBorderLeftColor {
297        inner: BORDER_FOCUS,
298    })),
299    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
300        StyleBorderRightColor {
301            inner: BORDER_FOCUS,
302        },
303    )),
304];
305
306/// The editable text inside the field — takes the remaining horizontal space.
307static COMBOBOX_TEXT_STYLE: &[CssPropertyWithConditions] = &[
308    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
309    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
310    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
311        LayoutPaddingRight::const_px(4),
312    )),
313];
314
315/// The drop-down arrow icon on the right of the field.
316static COMBOBOX_ARROW_STYLE: &[CssPropertyWithConditions] = &[
317    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
318    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(
319        ARROW_FONT_SIZE_PX,
320    ))),
321    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
322];
323
324/// Builds the floating options-list style. Only the `display` (open vs closed)
325/// differs; all positioning/visual props are present in both so the runtime
326/// `set_css_property(display)` toggle has everything it needs (mirroring the
327/// popover/accordion approach).
328fn build_list_style(open: bool) -> CssPropertyWithConditionsVec {
329    let display = if open {
330        LayoutDisplay::Block
331    } else {
332        LayoutDisplay::None
333    };
334    CssPropertyWithConditionsVec::from_vec(alloc::vec![
335        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
336        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
337        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(LIST_OFFSET_Y))),
338        CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
339        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
340            MIN_WIDTH,
341        ))),
342        // border: 1px solid #acacac
343        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
344            LayoutBorderTopWidth::const_px(1),
345        )),
346        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
347            LayoutBorderBottomWidth::const_px(1),
348        )),
349        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
350            LayoutBorderLeftWidth::const_px(1),
351        )),
352        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
353            LayoutBorderRightWidth::const_px(1),
354        )),
355        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
356            inner: BorderStyle::Solid,
357        })),
358        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
359            StyleBorderBottomStyle {
360                inner: BorderStyle::Solid,
361            },
362        )),
363        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
364            inner: BorderStyle::Solid,
365        })),
366        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
367            StyleBorderRightStyle {
368                inner: BorderStyle::Solid,
369            },
370        )),
371        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
372            inner: BORDER_COLOR,
373        })),
374        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
375            StyleBorderBottomColor {
376                inner: BORDER_COLOR,
377            },
378        )),
379        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
380            inner: BORDER_COLOR,
381        })),
382        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
383            StyleBorderRightColor {
384                inner: BORDER_COLOR,
385            },
386        )),
387        // border-radius: 4px
388        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
389            StyleBorderBottomLeftRadius::const_px(RADIUS),
390        )),
391        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
392            StyleBorderBottomRightRadius::const_px(RADIUS),
393        )),
394        CssPropertyWithConditions::simple(CssProperty::const_background_content(WHITE_BG_VEC)),
395    ])
396}
397
398/// Per-option row style: a padded, pointer-cursor block highlighted on hover.
399static COMBOBOX_OPTION_STYLE: &[CssPropertyWithConditions] = &[
400    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
401    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(6))),
402    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
403        LayoutPaddingBottom::const_px(6),
404    )),
405    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
406        10,
407    ))),
408    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
409        LayoutPaddingRight::const_px(10),
410    )),
411    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
412    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
413    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
414        inner: TEXT_COLOR,
415    })),
416    CssPropertyWithConditions::on_hover(CssProperty::const_background_content(OPTION_HOVER_BG_VEC)),
417];
418
419impl ComboBox {
420    /// Creates a new combobox with the given options (no callback, nothing typed).
421    #[must_use] pub fn new(items: StringVec) -> Self {
422        Self {
423            combo_state: ComboBoxStateWrapper {
424                inner: ComboBoxState::default(),
425                items,
426                on_select: None.into(),
427            },
428            placeholder: AzString::from_const_str(""),
429        }
430    }
431
432    /// Creates an empty combobox.
433    #[must_use] pub fn create() -> Self {
434        Self::new(StringVec::from_const_slice(&[]))
435    }
436
437    /// Sets the initially-selected option index.
438    #[inline]
439    pub const fn set_selected(&mut self, selected: usize) {
440        self.combo_state.inner.selected = selected;
441    }
442
443    /// Builder-style setter for the initially-selected index.
444    #[inline]
445    #[must_use] pub const fn with_selected(mut self, selected: usize) -> Self {
446        self.set_selected(selected);
447        self
448    }
449
450    /// Sets the initial (editable) field text.
451    #[inline]
452    pub fn set_text(&mut self, text: AzString) {
453        self.combo_state.inner.text = text;
454    }
455
456    /// Builder-style setter for the initial field text.
457    #[inline]
458    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
459        self.set_text(text);
460        self
461    }
462
463    /// Sets the greyed placeholder shown when the field is empty.
464    #[inline]
465    pub fn set_placeholder(&mut self, placeholder: AzString) {
466        self.placeholder = placeholder;
467    }
468
469    /// Builder-style setter for the placeholder.
470    #[inline]
471    #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
472        self.set_placeholder(placeholder);
473        self
474    }
475
476    /// Sets the callback invoked when an option is selected.
477    #[inline]
478    pub fn set_on_select<C: Into<ComboBoxOnSelectCallback>>(&mut self, data: RefAny, on_select: C) {
479        self.combo_state.on_select = Some(ComboBoxOnSelect {
480            callback: on_select.into(),
481            refany: data,
482        })
483        .into();
484    }
485
486    /// Builder-style setter for the select callback.
487    #[inline]
488    #[must_use] pub fn with_on_select<C: Into<ComboBoxOnSelectCallback>>(
489        mut self,
490        data: RefAny,
491        on_select: C,
492    ) -> Self {
493        self.set_on_select(data, on_select);
494        self
495    }
496
497    /// Replaces `self` with a default (empty) combobox and returns the original.
498    #[inline]
499    #[must_use] pub fn swap_with_default(&mut self) -> Self {
500        let mut s = Self::create();
501        core::mem::swap(&mut s, self);
502        s
503    }
504
505    /// Renders the combobox into a [`Dom`] subtree with the `__azul-native-combobox`
506    /// class.
507    #[must_use] pub fn dom(self) -> Dom {
508        // Initial field text: the typed/selected text if present, else the
509        // placeholder (a simplification — there is no separate placeholder node,
510        // so the placeholder is just the initial label and is replaced on the
511        // first keystroke or selection).
512        let field_text = if self.combo_state.inner.text.as_str().is_empty() {
513            self.placeholder.clone()
514        } else {
515            self.combo_state.inner.text.clone()
516        };
517
518        let open = self.combo_state.inner.open;
519        let items = self.combo_state.items.clone();
520
521        // ONE shared RefAny: the field handlers and every option handler all
522        // read/mutate the same ComboBoxStateWrapper (the text_input shared-state
523        // pattern), so open/selected/text stay in sync across interactions.
524        let state_ref = RefAny::new(self.combo_state);
525
526        let text_node = Dom::create_text(field_text)
527            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_TEXT_CLASS))
528            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_TEXT_STYLE));
529
530        let arrow = Dom::create_icon(AzString::from_const_str("arrow_drop_down"))
531            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_ARROW_CLASS))
532            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_ARROW_STYLE));
533
534        // The focusable, editable input field. Clicking it toggles the list
535        // (Hover::MouseUp) and focuses it; typing edits the text node
536        // (Focus::TextInput / VirtualKeyDown), mirroring text_input.
537        let field = Dom::create_div()
538            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_INPUT_CLASS))
539            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_INPUT_STYLE))
540            .with_tab_index(TabIndex::Auto)
541            .with_callbacks(
542                alloc::vec![
543                    CoreCallbackData {
544                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
545                        callback: CoreCallback {
546                            cb: on_combobox_toggle as usize,
547                            ctx: OptionRefAny::None,
548                        },
549                        refany: state_ref.clone(),
550                    },
551                    CoreCallbackData {
552                        event: EventFilter::Focus(FocusEventFilter::TextInput),
553                        callback: CoreCallback {
554                            cb: on_combobox_text_input as usize,
555                            ctx: OptionRefAny::None,
556                        },
557                        refany: state_ref.clone(),
558                    },
559                    CoreCallbackData {
560                        event: EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
561                        callback: CoreCallback {
562                            cb: on_combobox_key_down as usize,
563                            ctx: OptionRefAny::None,
564                        },
565                        refany: state_ref.clone(),
566                    },
567                ]
568                .into(),
569            )
570            .with_children(DomVec::from_vec(alloc::vec![text_node, arrow]));
571
572        // Build the option rows. Each carries a CLONE of the shared state so its
573        // click handler can mutate selected/open and read the chosen label.
574        let mut option_doms: Vec<Dom> = Vec::with_capacity(items.as_ref().len());
575        for option in items.as_ref() {
576            option_doms.push(
577                Dom::create_text(option.clone())
578                    .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_OPTION_CLASS))
579                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
580                        COMBOBOX_OPTION_STYLE,
581                    ))
582                    .with_tab_index(TabIndex::Auto)
583                    .with_callbacks(
584                        alloc::vec![CoreCallbackData {
585                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
586                            callback: CoreCallback {
587                                cb: on_combobox_option_click as usize,
588                                ctx: OptionRefAny::None,
589                            },
590                            refany: state_ref.clone(),
591                        }]
592                        .into(),
593                    ),
594            );
595        }
596
597        let list = Dom::create_div()
598            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_LIST_CLASS))
599            .with_css_props(build_list_style(open))
600            .with_children(DomVec::from_vec(option_doms));
601
602        Dom::create_div()
603            .with_ids_and_classes(IdOrClassVec::from_const_slice(COMBOBOX_WRAPPER_CLASS))
604            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(COMBOBOX_WRAPPER_STYLE))
605            // children: [field, list] — the list is the field's next sibling.
606            .with_children(DomVec::from_vec(alloc::vec![field, list]))
607    }
608}
609
610impl Default for ComboBox {
611    fn default() -> Self {
612        Self::create()
613    }
614}
615
616/// Field click handler. The hit node is the field; its next sibling is the list.
617/// Flips `open` on the shared state and shows/hides the list via `display`.
618extern "C" fn on_combobox_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
619    let field = info.get_hit_node();
620    let Some(list) = info.get_next_sibling(field) else {
621        return Update::DoNothing;
622    };
623
624    let now_open = {
625        let Some(mut combo) = data.downcast_mut::<ComboBoxStateWrapper>() else {
626            return Update::DoNothing;
627        };
628        combo.inner.open = !combo.inner.open;
629        combo.inner.open
630    };
631
632    // TODO2: shows/hides the list by toggling `display` via set_css_property; the
633    // display:none/block relayout itself is not GUI-verified in this build.
634    let display = if now_open {
635        LayoutDisplay::Block
636    } else {
637        LayoutDisplay::None
638    };
639    info.set_css_property(list, CssProperty::const_display(display));
640
641    Update::DoNothing
642}
643
644/// Field text-input handler — appends the typed character(s) to the editable
645/// field text (mirroring `text_input`). Does NOT re-filter the list (see the
646/// module-level type-to-filter `TODO2`).
647extern "C" fn on_combobox_text_input(data: RefAny, info: CallbackInfo) -> Update {
648    on_combobox_text_input_inner(data, info).unwrap_or(Update::DoNothing)
649}
650
651fn on_combobox_text_input_inner(mut data: RefAny, mut info: CallbackInfo) -> Option<Update> {
652    let field = info.get_hit_node();
653    let text_node = info.get_first_child(field)?;
654
655    let changeset = info.get_text_changeset()?;
656    let inserted_text = changeset.inserted_text.as_str().to_string();
657    if inserted_text.is_empty() {
658        return None;
659    }
660
661    let new_text = {
662        let mut combo = data.downcast_mut::<ComboBoxStateWrapper>()?;
663        let mut s: String = combo.inner.text.as_str().into();
664        s.push_str(&inserted_text);
665        combo.inner.text = s.clone().into();
666        s
667    };
668
669    info.change_node_text(text_node, new_text.into());
670    Some(Update::DoNothing)
671}
672
673/// Field key-down handler — implements backspace deletion (mirroring `text_input`).
674extern "C" fn on_combobox_key_down(data: RefAny, info: CallbackInfo) -> Update {
675    on_combobox_key_down_inner(data, info).unwrap_or(Update::DoNothing)
676}
677
678fn on_combobox_key_down_inner(mut data: RefAny, mut info: CallbackInfo) -> Option<Update> {
679    let field = info.get_hit_node();
680    let text_node = info.get_first_child(field)?;
681
682    let keyboard_state = info.get_current_keyboard_state();
683    let c = keyboard_state.current_virtual_keycode.into_option()?;
684    if c != VirtualKeyCode::Back {
685        return None;
686    }
687
688    let new_text = {
689        let mut combo = data.downcast_mut::<ComboBoxStateWrapper>()?;
690        let mut s: String = combo.inner.text.as_str().into();
691        s.pop();
692        combo.inner.text = s.clone().into();
693        s
694    };
695
696    info.change_node_text(text_node, new_text.into());
697    Some(Update::DoNothing)
698}
699
700/// Option click handler. The hit node is the clicked option; its index is the
701/// number of previous siblings. Its parent is the list; the list's parent is the
702/// wrapper, whose first child is the field, whose first child is the text node.
703/// Fills the field with the option's label, sets `selected`, closes the list, and
704/// invokes the optional user callback.
705extern "C" fn on_combobox_option_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
706    let option = info.get_hit_node();
707
708    // index = number of previous siblings.
709    let mut index = 0usize;
710    let mut cursor = option;
711    while let Some(prev) = info.get_previous_sibling(cursor) {
712        index += 1;
713        cursor = prev;
714    }
715
716    let Some(list) = info.get_parent(option) else {
717        return Update::DoNothing;
718    };
719    let Some(wrapper) = info.get_parent(list) else {
720        return Update::DoNothing;
721    };
722    let Some(field) = info.get_first_child(wrapper) else {
723        return Update::DoNothing;
724    };
725    let Some(text_node) = info.get_first_child(field) else {
726        return Update::DoNothing;
727    };
728
729    let (label, inner, result) = {
730        let Some(mut combo) = data.downcast_mut::<ComboBoxStateWrapper>() else {
731            return Update::DoNothing;
732        };
733        let Some(label) = combo.items.as_ref().get(index).cloned() else {
734            return Update::DoNothing;
735        };
736        combo.inner.selected = index;
737        combo.inner.text = label.clone();
738        combo.inner.open = false;
739        let inner = combo.inner.clone();
740        let combo = &mut *combo;
741        let result = match combo.on_select.as_mut() {
742            Some(ComboBoxOnSelect { callback, refany }) => {
743                (callback.cb)(refany.clone(), info, inner.clone())
744            }
745            None => Update::DoNothing,
746        };
747        (label, inner, result)
748    };
749    drop(inner);
750
751    // Fill the field with the chosen label and close the list.
752    info.change_node_text(text_node, label);
753    info.set_css_property(list, CssProperty::const_display(LayoutDisplay::None));
754
755    result
756}
757
758impl From<ComboBox> for Dom {
759    fn from(c: ComboBox) -> Self {
760        c.dom()
761    }
762}
763
764#[cfg(all(test, feature = "std"))]
765#[allow(clippy::too_many_lines)]
766// `redundant_closure`: NOT redundant here. `run()` takes
767// `impl FnOnce(RefAny, CallbackInfo) -> R`; `CallbackInfo` carries an elided
768// lifetime, so the bound is higher-ranked (`for<'a> FnOnce(_, CallbackInfo<'a>)`).
769// The handlers are `extern "C" fn` items, which do NOT satisfy a higher-ranked
770// `FnOnce` bound — passing one bare fails to compile with E0277. The `|r, ci| f(r, ci)`
771// wrapper is what makes the coercion happen and must stay.
772#[allow(clippy::redundant_closure)]
773mod autotest_generated {
774    use std::{
775        cell::{Cell, RefCell},
776        collections::{BTreeMap, HashMap},
777        sync::{Arc, Mutex},
778    };
779
780    use azul_core::{
781        dom::{DomId, DomNodeId, NodeId, NodeType},
782        geom::{LogicalRect, OptionLogicalPosition},
783        gl::OptionGlContextPtr,
784        hit_test::ScrollPosition,
785        resources::RendererResources,
786        styled_dom::{NodeHierarchyItemId, StyledDom},
787        window::{MonitorVec, RawWindowHandle},
788    };
789    use azul_css::system::SystemStyle;
790    use rust_fontconfig::FcFontCache;
791
792    use super::*;
793    #[cfg(feature = "icu")]
794    use crate::icu::IcuLocalizerHandle;
795    use crate::{
796        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
797        managers::text_input::PendingTextEdit,
798        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
799        window::{DomLayoutResult, LayoutWindow},
800        window_state::FullWindowState,
801    };
802
803    // ------------------------------------------------------------------
804    // Fixtures / helpers
805    // ------------------------------------------------------------------
806
807    /// A `StringVec` of options from string literals.
808    fn sv(items: &[&str]) -> StringVec {
809        StringVec::from_vec(items.iter().map(|s| AzString::from(*s)).collect())
810    }
811
812    /// True if `node` carries the CSS class `name`.
813    fn has_class(node: &Dom, name: &str) -> bool {
814        node.root
815            .get_ids_and_classes()
816            .as_ref()
817            .iter()
818            .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
819    }
820
821    /// The text of a `NodeType::Text` node (`None` for any other node type).
822    fn text_of(node: &Dom) -> Option<&str> {
823        match node.root.get_node_type() {
824            NodeType::Text(s) => Some(s.as_ref().as_str()),
825            _ => None,
826        }
827    }
828
829    /// The icon name of a `NodeType::Icon` node.
830    fn icon_of(node: &Dom) -> Option<&str> {
831        match node.root.get_node_type() {
832            NodeType::Icon(s) => Some(s.as_ref().as_str()),
833            _ => None,
834        }
835    }
836
837    /// The `display` value in a node's *inline* style, if it sets one.
838    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
839        node.root
840            .style
841            .iter_inline_properties()
842            .find_map(|(p, _)| match p {
843                CssProperty::Display(v) => v.get_property().copied(),
844                _ => None,
845            })
846    }
847
848    /// The `display` declared in a built style vec.
849    fn display_of(props: &CssPropertyWithConditionsVec) -> Option<LayoutDisplay> {
850        props.as_ref().iter().find_map(|p| match &p.property {
851            CssProperty::Display(v) => v.get_property().copied(),
852            _ => None,
853        })
854    }
855
856    /// The `position` declared in a built style vec.
857    fn position_of(props: &CssPropertyWithConditionsVec) -> Option<LayoutPosition> {
858        props.as_ref().iter().find_map(|p| match &p.property {
859            CssProperty::Position(v) => v.get_property().copied(),
860            _ => None,
861        })
862    }
863
864    /// `(field, list)` of a rendered combobox DOM.
865    fn parts(dom: &Dom) -> (&Dom, &Dom) {
866        let children = dom.children.as_ref();
867        assert_eq!(children.len(), 2, "a combobox is exactly [field, list]");
868        (&children[0], &children[1])
869    }
870
871    /// Flattened indices of every node carrying `class`, in tree order. Used
872    /// instead of hard-coded indices so the tests do not encode the DOM
873    /// flattening order.
874    fn nodes_with_class(styled: &StyledDom, class: &str) -> Vec<usize> {
875        styled
876            .node_data
877            .as_ref()
878            .iter()
879            .enumerate()
880            .filter(|(_, nd)| {
881                nd.get_ids_and_classes()
882                    .as_ref()
883                    .iter()
884                    .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == class))
885            })
886            .map(|(i, _)| i)
887            .collect()
888    }
889
890    /// A styled `ComboBox::new(items).dom()` plus the flattened index of every
891    /// node the handlers navigate to.
892    struct Fixture {
893        styled: StyledDom,
894        wrapper: usize,
895        field: usize,
896        text: usize,
897        list: usize,
898        options: Vec<usize>,
899    }
900
901    fn fixture(items: &[&str]) -> Fixture {
902        let styled = StyledDom::create_from_dom(ComboBox::new(sv(items)).dom());
903
904        fn one(styled: &StyledDom, class: &str) -> usize {
905            let found = nodes_with_class(styled, class);
906            assert_eq!(found.len(), 1, "expected exactly one `{class}` node");
907            found[0]
908        }
909
910        let wrapper = one(&styled, "__azul-native-combobox");
911        let field = one(&styled, "__azul-native-combobox-input");
912        let text = one(&styled, "__azul-native-combobox-text");
913        let list = one(&styled, "__azul-native-combobox-list");
914        let options = nodes_with_class(&styled, "__azul-native-combobox-option");
915        assert_eq!(options.len(), items.len());
916
917        Fixture {
918            styled,
919            wrapper,
920            field,
921            text,
922            list,
923            options,
924        }
925    }
926
927    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
928    fn node(idx: usize) -> DomNodeId {
929        DomNodeId {
930            dom: DomId::ROOT_ID,
931            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
932        }
933    }
934
935    /// A `DomLayoutResult` with an *empty* layout tree: these handlers only walk
936    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
937    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
938        DomLayoutResult {
939            styled_dom,
940            layout_tree: LayoutTree {
941                nodes: Vec::new(),
942                warm: Vec::new(),
943                cold: Vec::new(),
944                root: 0,
945                dom_to_layout: BTreeMap::new(),
946                children_arena: Vec::new(),
947                children_offsets: Vec::new(),
948                subtree_needs_intrinsic: Vec::new(),
949            },
950            calculated_positions: Vec::new(),
951            viewport: LogicalRect::zero(),
952            display_list: DisplayList::default(),
953            scroll_ids: HashMap::new(),
954            scroll_id_to_node_id: HashMap::new(),
955        }
956    }
957
958    /// Everything the combobox handlers read out of the window: the styled DOM
959    /// they navigate, the pending text changeset, and the pressed key.
960    #[derive(Default)]
961    struct Env {
962        styled: Option<StyledDom>,
963        changeset: Option<PendingTextEdit>,
964        keycode: Option<VirtualKeyCode>,
965    }
966
967    /// Invokes `call` against a `LayoutWindow` built from `env`, with `hit` as the
968    /// hit node. Returns the handler's value plus every recorded `CallbackChange`.
969    fn run<R>(
970        env: Env,
971        hit: usize,
972        data: RefAny,
973        call: impl FnOnce(RefAny, CallbackInfo) -> R,
974    ) -> (R, Vec<CallbackChange>) {
975        let mut layout_window =
976            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
977        if let Some(sd) = env.styled {
978            layout_window
979                .layout_results
980                .insert(DomId::ROOT_ID, layout_result(sd));
981        }
982        layout_window.text_input_manager.pending_changeset = env.changeset;
983
984        let renderer_resources = RendererResources::default();
985        let previous_window_state: Option<FullWindowState> = None;
986        let mut current_window_state = FullWindowState::default();
987        current_window_state.keyboard_state.current_virtual_keycode = env.keycode.into();
988        let gl_context = OptionGlContextPtr::None;
989        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
990            BTreeMap::new();
991        let window_handle = RawWindowHandle::Unsupported;
992        let system_callbacks = ExternalSystemCallbacks::rust_internal();
993
994        let ref_data = CallbackInfoRefData {
995            layout_window: &layout_window,
996            renderer_resources: &renderer_resources,
997            previous_window_state: &previous_window_state,
998            current_window_state: &current_window_state,
999            gl_context: &gl_context,
1000            current_scroll_manager: &scroll_states,
1001            current_window_handle: &window_handle,
1002            system_callbacks: &system_callbacks,
1003            system_style: Arc::new(SystemStyle::default()),
1004            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1005            #[cfg(feature = "icu")]
1006            icu_localizer: IcuLocalizerHandle::default(),
1007            ctx: OptionRefAny::None,
1008        };
1009
1010        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1011
1012        let info = CallbackInfo::new(
1013            &ref_data,
1014            &changes,
1015            node(hit),
1016            OptionLogicalPosition::None,
1017            OptionLogicalPosition::None,
1018        );
1019
1020        let out = call(data, info);
1021        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
1022        (out, recorded)
1023    }
1024
1025    /// Every `display` write in the change log, as `(node index, display)`.
1026    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
1027        let mut out = Vec::new();
1028        for change in changes {
1029            if let CallbackChange::ChangeNodeCssProperties {
1030                node_id, properties, ..
1031            } = change
1032            {
1033                for p in properties.as_ref() {
1034                    if let CssProperty::Display(v) = p {
1035                        if let Some(d) = v.get_property() {
1036                            out.push((node_id.index(), *d));
1037                        }
1038                    }
1039                }
1040            }
1041        }
1042        out
1043    }
1044
1045    /// Every text write in the change log, as `(node index, new text)`.
1046    fn text_writes(changes: &[CallbackChange]) -> Vec<(usize, String)> {
1047        changes
1048            .iter()
1049            .filter_map(|change| match change {
1050                CallbackChange::ChangeNodeText { node_id, text } => Some((
1051                    node_id
1052                        .node
1053                        .into_crate_internal()
1054                        .expect("a text write always targets a real node")
1055                        .index(),
1056                    text.as_str().to_string(),
1057                )),
1058                _ => None,
1059            })
1060            .collect()
1061    }
1062
1063    /// A `ComboBoxStateWrapper` payload with no user callback.
1064    fn state(items: &[&str], text: &str, open: bool, selected: usize) -> RefAny {
1065        RefAny::new(ComboBoxStateWrapper {
1066            inner: ComboBoxState {
1067                open,
1068                selected,
1069                text: AzString::from(text),
1070            },
1071            items: sv(items),
1072            on_select: None.into(),
1073        })
1074    }
1075
1076    /// Reads the (still shared) `ComboBoxState` back out of a payload.
1077    fn inner_of(data: &mut RefAny) -> ComboBoxState {
1078        data.downcast_ref::<ComboBoxStateWrapper>()
1079            .expect("payload must still be a ComboBoxStateWrapper")
1080            .inner
1081            .clone()
1082    }
1083
1084    fn on_select_cb(f: ComboBoxOnSelectCallbackType) -> ComboBoxOnSelectCallback {
1085        f.into()
1086    }
1087
1088    /// Records every `ComboBoxState` a user `on_select` was handed.
1089    struct SelectLog {
1090        calls: Vec<ComboBoxState>,
1091    }
1092
1093    extern "C" fn record_select(mut data: RefAny, _: CallbackInfo, s: ComboBoxState) -> Update {
1094        if let Some(mut log) = data.downcast_mut::<SelectLog>() {
1095            log.calls.push(s);
1096        }
1097        Update::RefreshDom
1098    }
1099
1100    extern "C" fn select_do_nothing(_: RefAny, _: CallbackInfo, _: ComboBoxState) -> Update {
1101        Update::DoNothing
1102    }
1103
1104    thread_local! {
1105        /// A clone of the shared state handle, smuggled into `probe_reborrow`
1106        /// without building a self-referential `RefAny` cycle.
1107        static SHARED_ALIAS: RefCell<Option<RefAny>> = const { RefCell::new(None) };
1108        /// `Some(true)` once `probe_reborrow` has seen the re-borrow refused.
1109        static REBORROW_REFUSED: Cell<Option<bool>> = const { Cell::new(None) };
1110    }
1111
1112    extern "C" fn probe_reborrow(_: RefAny, _: CallbackInfo, _: ComboBoxState) -> Update {
1113        let refused = SHARED_ALIAS.with(|alias| {
1114            alias
1115                .borrow_mut()
1116                .as_mut()
1117                .expect("alias installed by the test")
1118                .downcast_mut::<ComboBoxStateWrapper>()
1119                .is_none()
1120        });
1121        REBORROW_REFUSED.with(|c| c.set(Some(refused)));
1122        Update::DoNothing
1123    }
1124
1125    // ------------------------------------------------------------------
1126    // build_list_style
1127    // ------------------------------------------------------------------
1128
1129    #[test]
1130    fn build_list_style_differs_only_in_display() {
1131        let closed = build_list_style(false);
1132        let open = build_list_style(true);
1133        let (c, o) = (closed.as_ref(), open.as_ref());
1134
1135        assert!(!c.is_empty(), "the list style must not be empty");
1136        assert_eq!(
1137            c.len(),
1138            o.len(),
1139            "open and closed must declare the same property set so the runtime \
1140             `set_css_property(display)` toggle has everything it needs"
1141        );
1142
1143        let differing: Vec<usize> = (0..c.len()).filter(|&i| c[i] != o[i]).collect();
1144        assert_eq!(
1145            differing.len(),
1146            1,
1147            "exactly one property may differ between open and closed"
1148        );
1149        assert!(matches!(
1150            &c[differing[0]].property,
1151            CssProperty::Display(_)
1152        ));
1153    }
1154
1155    #[test]
1156    fn build_list_style_display_follows_the_flag() {
1157        assert_eq!(
1158            display_of(&build_list_style(false)),
1159            Some(LayoutDisplay::None),
1160            "a closed list is hidden"
1161        );
1162        assert_eq!(
1163            display_of(&build_list_style(true)),
1164            Some(LayoutDisplay::Block),
1165            "an open list is shown"
1166        );
1167    }
1168
1169    #[test]
1170    fn build_list_style_always_positions_absolutely() {
1171        // Positioning must be present in BOTH states — the toggle only rewrites
1172        // `display`, so a missing `position` in the closed style would leave the
1173        // list statically positioned once opened.
1174        for open in [false, true] {
1175            let props = build_list_style(open);
1176            assert_eq!(
1177                position_of(&props),
1178                Some(LayoutPosition::Absolute),
1179                "open={open}"
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn build_list_style_is_deterministic_and_unshared() {
1186        // Two calls with the same flag must be equal, and neither may alias the
1187        // other (it allocates a fresh vec every call).
1188        assert_eq!(build_list_style(true), build_list_style(true));
1189        assert_eq!(build_list_style(false), build_list_style(false));
1190        assert_ne!(build_list_style(true), build_list_style(false));
1191    }
1192
1193    // ------------------------------------------------------------------
1194    // ComboBox::new / create / Default
1195    // ------------------------------------------------------------------
1196
1197    #[test]
1198    fn new_stores_items_and_starts_at_documented_defaults() {
1199        let combo = ComboBox::new(sv(&["a", "b", "c"]));
1200
1201        assert_eq!(combo.combo_state.items.as_ref().len(), 3);
1202        assert_eq!(combo.combo_state.items.as_ref()[2].as_str(), "c");
1203        assert_eq!(combo.combo_state.inner, ComboBoxState::default());
1204        assert!(!combo.combo_state.inner.open, "the list starts closed");
1205        assert_eq!(combo.combo_state.inner.selected, 0);
1206        assert!(combo.combo_state.inner.text.as_str().is_empty());
1207        assert!(combo.placeholder.as_str().is_empty());
1208        assert!(
1209            combo.combo_state.on_select.is_none(),
1210            "ComboBox::new sets no callback"
1211        );
1212    }
1213
1214    #[test]
1215    fn new_survives_extreme_item_lists() {
1216        let long = "ab".repeat(50_000);
1217        let cases: Vec<Vec<AzString>> = alloc::vec![
1218            Vec::new(),
1219            alloc::vec![AzString::from("")],
1220            alloc::vec![AzString::from("a\0b"), AzString::from("")],
1221            alloc::vec![AzString::from(
1222                "👨‍👩‍👧‍👦 e\u{0301}\u{0327} مرحبا שלום 🇩🇪"
1223            )],
1224            alloc::vec![AzString::from("\u{feff}\u{202e}rtl-override")],
1225            alloc::vec![AzString::from(long.as_str())],
1226        ];
1227
1228        for items in cases {
1229            let combo = ComboBox::new(StringVec::from_vec(items.clone()));
1230            assert_eq!(combo.combo_state.items.as_ref(), items.as_slice());
1231
1232            // ...and every option survives the trip through the DOM byte-for-byte
1233            let dom = combo.dom();
1234            let (_, list) = parts(&dom);
1235            assert_eq!(list.children.as_ref().len(), items.len());
1236            for (i, item) in items.iter().enumerate() {
1237                assert_eq!(text_of(&list.children.as_ref()[i]), Some(item.as_str()));
1238            }
1239        }
1240    }
1241
1242    #[test]
1243    fn new_handles_many_duplicate_items() {
1244        // Duplicates are legal: selection is by index, not by label.
1245        let items = sv(&["same"; 512]);
1246        let combo = ComboBox::new(items);
1247        assert_eq!(combo.combo_state.items.as_ref().len(), 512);
1248
1249        let dom = combo.dom();
1250        let (_, list) = parts(&dom);
1251        assert_eq!(list.children.as_ref().len(), 512);
1252    }
1253
1254    #[test]
1255    fn create_is_empty_and_equals_default() {
1256        let combo = ComboBox::create();
1257        assert!(combo.combo_state.items.as_ref().is_empty());
1258        assert!(combo.combo_state.on_select.is_none());
1259        assert_eq!(combo.combo_state.inner, ComboBoxState::default());
1260        assert_eq!(combo, ComboBox::default());
1261        // repeated calls are independent, equal values
1262        assert_eq!(ComboBox::create(), ComboBox::create());
1263    }
1264
1265    // ------------------------------------------------------------------
1266    // set_selected / with_selected  (numeric)
1267    // ------------------------------------------------------------------
1268
1269    #[test]
1270    fn set_selected_stores_every_index_verbatim() {
1271        // The setter is documented as a plain store: no clamping to items.len(),
1272        // no saturation, no wrap — assert exactly that at both ends of usize.
1273        for value in [0usize, 1, 2, usize::MAX - 1, usize::MAX] {
1274            let mut combo = ComboBox::new(sv(&["a", "b"]));
1275            combo.set_selected(value);
1276            assert_eq!(combo.combo_state.inner.selected, value);
1277            // nothing else moved
1278            assert!(!combo.combo_state.inner.open);
1279            assert!(combo.combo_state.inner.text.as_str().is_empty());
1280            assert_eq!(combo.combo_state.items.as_ref().len(), 2);
1281        }
1282    }
1283
1284    #[test]
1285    fn set_selected_last_write_wins() {
1286        let mut combo = ComboBox::create();
1287        combo.set_selected(usize::MAX);
1288        combo.set_selected(0);
1289        assert_eq!(combo.combo_state.inner.selected, 0);
1290        combo.set_selected(7);
1291        combo.set_selected(7);
1292        assert_eq!(combo.combo_state.inner.selected, 7, "idempotent re-set");
1293    }
1294
1295    #[test]
1296    fn with_selected_matches_set_selected() {
1297        for value in [0usize, 3, usize::MAX] {
1298            let built = ComboBox::new(sv(&["a"])).with_selected(value);
1299            let mut mutated = ComboBox::new(sv(&["a"]));
1300            mutated.set_selected(value);
1301            assert_eq!(built, mutated);
1302        }
1303    }
1304
1305    #[test]
1306    fn out_of_range_selected_still_renders_without_panicking() {
1307        // `dom()` never indexes `items` by `selected`, so an out-of-range index
1308        // (including usize::MAX on an EMPTY item list) must render fine.
1309        for (items, selected) in [
1310            (alloc::vec![], usize::MAX),
1311            (alloc::vec!["a"], 99),
1312            (alloc::vec!["a", "b"], usize::MAX - 1),
1313        ] {
1314            let combo = ComboBox::new(sv(&items)).with_selected(selected);
1315            assert_eq!(combo.combo_state.inner.selected, selected);
1316            let dom = combo.dom();
1317            let (_, list) = parts(&dom);
1318            assert_eq!(list.children.as_ref().len(), items.len());
1319        }
1320    }
1321
1322    // ------------------------------------------------------------------
1323    // set_text / with_text
1324    // ------------------------------------------------------------------
1325
1326    #[test]
1327    fn set_text_stores_every_string_verbatim() {
1328        let long = "x".repeat(100_000);
1329        let cases = [
1330            "",
1331            " ",
1332            "a\0b",
1333            "line\nbreak\ttab",
1334            "👨‍👩‍👧‍👦 e\u{0301}\u{0327} مرحبا שלום 🇩🇪",
1335            "\u{feff}\u{202e}rtl",
1336            long.as_str(),
1337        ];
1338
1339        for case in cases {
1340            let mut combo = ComboBox::create();
1341            combo.set_text(AzString::from(case));
1342            assert_eq!(combo.combo_state.inner.text.as_str(), case);
1343            assert_eq!(
1344                combo.combo_state.inner.text.as_str().len(),
1345                case.len(),
1346                "no truncation at the NUL or anywhere else"
1347            );
1348        }
1349    }
1350
1351    #[test]
1352    fn with_text_matches_set_text_and_last_write_wins() {
1353        let built = ComboBox::create().with_text("a".into()).with_text("b".into());
1354        let mut mutated = ComboBox::create();
1355        mutated.set_text("a".into());
1356        mutated.set_text("b".into());
1357        assert_eq!(built, mutated);
1358        assert_eq!(built.combo_state.inner.text.as_str(), "b");
1359    }
1360
1361    // ------------------------------------------------------------------
1362    // set_placeholder / with_placeholder
1363    // ------------------------------------------------------------------
1364
1365    #[test]
1366    fn set_placeholder_stores_verbatim_and_does_not_touch_text() {
1367        let mut combo = ComboBox::new(sv(&["a"]));
1368        combo.set_placeholder("Pick one…\u{0}".into());
1369        assert_eq!(combo.placeholder.as_str(), "Pick one…\u{0}");
1370        assert!(
1371            combo.combo_state.inner.text.as_str().is_empty(),
1372            "the placeholder is not the value"
1373        );
1374
1375        let built = ComboBox::new(sv(&["a"])).with_placeholder("Pick one…\u{0}".into());
1376        assert_eq!(built, combo);
1377    }
1378
1379    #[test]
1380    fn placeholder_is_the_field_label_only_while_text_is_empty() {
1381        // Documented simplification: there is no separate placeholder node, so the
1382        // field label is `text` if non-empty, else `placeholder`.
1383        let dom = ComboBox::create().with_placeholder("ph".into()).dom();
1384        let (field, _) = parts(&dom);
1385        assert_eq!(text_of(&field.children.as_ref()[0]), Some("ph"));
1386
1387        // a single SPACE is non-empty, so it must win over the placeholder
1388        let spaced = ComboBox::create()
1389            .with_placeholder("ph".into())
1390            .with_text(" ".into());
1391        let dom = spaced.dom();
1392        let (field, _) = parts(&dom);
1393        assert_eq!(text_of(&field.children.as_ref()[0]), Some(" "));
1394
1395        // ...and with no placeholder and no text the label is the empty string
1396        let bare = ComboBox::create().dom();
1397        let (field, _) = parts(&bare);
1398        assert_eq!(text_of(&field.children.as_ref()[0]), Some(""));
1399    }
1400
1401    // ------------------------------------------------------------------
1402    // set_on_select / with_on_select
1403    // ------------------------------------------------------------------
1404
1405    #[test]
1406    fn set_on_select_last_call_wins() {
1407        let mut combo = ComboBox::create();
1408
1409        combo.set_on_select(RefAny::new(1u8), on_select_cb(select_do_nothing));
1410        assert!(combo.combo_state.on_select.is_some());
1411
1412        // a second call must *replace* (not append / leak / panic)
1413        combo.set_on_select(RefAny::new(9i64), on_select_cb(record_select));
1414        let set = combo.combo_state.on_select.as_ref().expect("still Some");
1415        assert_eq!(set.refany.get_type_id(), RefAny::new(0i64).get_type_id());
1416        assert_eq!(set.callback, on_select_cb(record_select));
1417        assert_ne!(set.callback, on_select_cb(select_do_nothing));
1418    }
1419
1420    #[test]
1421    fn with_on_select_matches_set_on_select() {
1422        let built = ComboBox::new(sv(&["a"]))
1423            .with_on_select(RefAny::new(7u32), on_select_cb(record_select));
1424
1425        let mut mutated = ComboBox::new(sv(&["a"]));
1426        mutated.set_on_select(RefAny::new(7u32), on_select_cb(record_select));
1427
1428        assert_eq!(
1429            built.combo_state.on_select.as_ref().unwrap().callback,
1430            mutated.combo_state.on_select.as_ref().unwrap().callback
1431        );
1432        // the builder form must not disturb the items or the state
1433        assert_eq!(built.combo_state.items.as_ref().len(), 1);
1434        assert_eq!(built.combo_state.inner, ComboBoxState::default());
1435    }
1436
1437    // ------------------------------------------------------------------
1438    // swap_with_default
1439    // ------------------------------------------------------------------
1440
1441    #[test]
1442    fn swap_with_default_moves_all_state_out() {
1443        let mut combo = ComboBox::new(sv(&["a", "b"]))
1444            .with_selected(1)
1445            .with_text("typed".into())
1446            .with_placeholder("ph".into())
1447            .with_on_select(RefAny::new(5u8), on_select_cb(record_select));
1448
1449        let original = combo.swap_with_default();
1450
1451        assert_eq!(original.combo_state.items.as_ref().len(), 2);
1452        assert_eq!(original.combo_state.inner.selected, 1);
1453        assert_eq!(original.combo_state.inner.text.as_str(), "typed");
1454        assert_eq!(original.placeholder.as_str(), "ph");
1455        assert!(original.combo_state.on_select.is_some());
1456
1457        assert_eq!(combo, ComboBox::create(), "self must be left empty");
1458
1459        // swapping an already-empty combobox is a no-op, not a panic
1460        let second = combo.swap_with_default();
1461        assert_eq!(second, ComboBox::create());
1462        assert_eq!(combo, ComboBox::create());
1463    }
1464
1465    // ------------------------------------------------------------------
1466    // ComboBox::dom
1467    // ------------------------------------------------------------------
1468
1469    #[test]
1470    fn dom_of_empty_combobox_still_has_field_and_empty_list() {
1471        let dom = ComboBox::create().dom();
1472        assert!(has_class(&dom, "__azul-native-combobox"));
1473
1474        let (field, list) = parts(&dom);
1475        assert!(has_class(field, "__azul-native-combobox-input"));
1476        assert!(has_class(list, "__azul-native-combobox-list"));
1477        assert!(
1478            list.children.as_ref().is_empty(),
1479            "no items -> no option rows"
1480        );
1481        // the field still has its text node + arrow
1482        assert_eq!(field.children.as_ref().len(), 2);
1483    }
1484
1485    #[test]
1486    fn dom_structure_classes_and_callbacks() {
1487        let dom = ComboBox::new(sv(&["one", "two"])).dom();
1488        let (field, list) = parts(&dom);
1489
1490        let text_node = &field.children.as_ref()[0];
1491        let arrow = &field.children.as_ref()[1];
1492        assert!(has_class(text_node, "__azul-native-combobox-text"));
1493        assert!(has_class(arrow, "__azul-native-combobox-arrow"));
1494        assert_eq!(icon_of(arrow), Some("arrow_drop_down"));
1495
1496        // the field is focusable and wires exactly toggle / text-input / key-down
1497        assert!(matches!(field.root.get_tab_index(), Some(TabIndex::Auto)));
1498        let cbs = field.root.get_callbacks();
1499        assert_eq!(cbs.len(), 3);
1500        assert_eq!(
1501            cbs.as_ref()[0].event,
1502            EventFilter::Hover(HoverEventFilter::MouseUp)
1503        );
1504        assert_eq!(cbs.as_ref()[0].callback.cb, on_combobox_toggle as usize);
1505        assert_eq!(
1506            cbs.as_ref()[1].event,
1507            EventFilter::Focus(FocusEventFilter::TextInput)
1508        );
1509        assert_eq!(cbs.as_ref()[1].callback.cb, on_combobox_text_input as usize);
1510        assert_eq!(
1511            cbs.as_ref()[2].event,
1512            EventFilter::Focus(FocusEventFilter::VirtualKeyDown)
1513        );
1514        assert_eq!(cbs.as_ref()[2].callback.cb, on_combobox_key_down as usize);
1515
1516        // every option is focusable and carries exactly one click handler
1517        for (i, option) in list.children.as_ref().iter().enumerate() {
1518            assert!(has_class(option, "__azul-native-combobox-option"));
1519            assert_eq!(text_of(option), Some(["one", "two"][i]));
1520            assert!(matches!(option.root.get_tab_index(), Some(TabIndex::Auto)));
1521            let cbs = option.root.get_callbacks();
1522            assert_eq!(cbs.len(), 1);
1523            assert_eq!(
1524                cbs.as_ref()[0].event,
1525                EventFilter::Hover(HoverEventFilter::MouseUp)
1526            );
1527            assert_eq!(
1528                cbs.as_ref()[0].callback.cb,
1529                on_combobox_option_click as usize
1530            );
1531        }
1532    }
1533
1534    #[test]
1535    fn dom_list_display_follows_open() {
1536        let closed = ComboBox::new(sv(&["a"])).dom();
1537        assert_eq!(inline_display(parts(&closed).1), Some(LayoutDisplay::None));
1538
1539        let mut open = ComboBox::new(sv(&["a"]));
1540        open.combo_state.inner.open = true;
1541        let open = open.dom();
1542        assert_eq!(inline_display(parts(&open).1), Some(LayoutDisplay::Block));
1543    }
1544
1545    #[test]
1546    fn dom_shares_exactly_one_refany_across_every_callback() {
1547        // The module doc promises ONE shared RefAny: a write through the field's
1548        // handle must be visible through every option's handle.
1549        let dom = ComboBox::new(sv(&["a", "b", "c"])).dom();
1550        let (field, list) = parts(&dom);
1551
1552        let field_refany = &field.root.get_callbacks().as_ref()[0].refany;
1553        for cb in field.root.get_callbacks().as_ref() {
1554            assert_eq!(&cb.refany, field_refany, "field handlers share one state");
1555        }
1556        for option in list.children.as_ref() {
1557            assert_eq!(
1558                &option.root.get_callbacks().as_ref()[0].refany,
1559                field_refany,
1560                "option handlers share the field's state"
1561            );
1562        }
1563
1564        // ...and it is actually the same allocation, not just an equal one
1565        let mut writer = field_refany.clone();
1566        {
1567            let mut w = writer
1568                .downcast_mut::<ComboBoxStateWrapper>()
1569                .expect("the shared payload is a ComboBoxStateWrapper");
1570            w.inner.selected = 2;
1571            w.inner.open = true;
1572        }
1573        let mut reader = list.children.as_ref()[0].root.get_callbacks().as_ref()[0]
1574            .refany
1575            .clone();
1576        let seen = inner_of(&mut reader);
1577        assert_eq!(seen.selected, 2);
1578        assert!(seen.open);
1579    }
1580
1581    #[test]
1582    fn dom_round_trips_items_and_state_into_the_shared_payload() {
1583        let combo = ComboBox::new(sv(&["α", "β", "\0"]))
1584            .with_selected(2)
1585            .with_text("typed".into());
1586        let expected = combo.combo_state.clone();
1587
1588        let dom = combo.dom();
1589        let mut shared = parts(&dom).0.root.get_callbacks().as_ref()[0]
1590            .refany
1591            .clone();
1592        let decoded = shared
1593            .downcast_ref::<ComboBoxStateWrapper>()
1594            .expect("payload type is preserved");
1595
1596        assert_eq!(decoded.inner, expected.inner);
1597        assert_eq!(decoded.items.as_ref(), expected.items.as_ref());
1598        assert!(decoded.on_select.is_none());
1599    }
1600
1601    #[test]
1602    fn dom_child_count_cache_stays_consistent() {
1603        // A wrong `estimated_total_children` under-allocates the compact-DOM
1604        // arena and panics much later.
1605        for items in [
1606            alloc::vec![],
1607            alloc::vec!["a"],
1608            alloc::vec!["a", "", "\u{1F600}"],
1609        ] {
1610            let dom = ComboBox::new(sv(&items))
1611                .with_placeholder("ph".into())
1612                .dom();
1613            assert_eq!(
1614                dom.estimated_total_children,
1615                dom.recompute_estimated_total_children(),
1616                "cached descendant count desynced for {} item(s)",
1617                items.len()
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn from_combobox_for_dom_renders_the_same_tree() {
1624        // `Dom::from` delegates to `dom()`; the trees are structurally identical
1625        // (they are NOT `==`, because each render mints a fresh shared `RefAny`).
1626        let combo = ComboBox::new(sv(&["a", "b"])).with_text("t".into());
1627        let via_from = Dom::from(combo.clone());
1628        let via_dom = combo.dom();
1629
1630        assert_eq!(
1631            via_from.estimated_total_children,
1632            via_dom.estimated_total_children
1633        );
1634        let (ff, fl) = parts(&via_from);
1635        let (df, dl) = parts(&via_dom);
1636        assert_eq!(text_of(&ff.children.as_ref()[0]), text_of(&df.children.as_ref()[0]));
1637        assert_eq!(inline_display(fl), inline_display(dl));
1638        assert_eq!(fl.children.as_ref().len(), dl.children.as_ref().len());
1639        assert_ne!(
1640            ff.root.get_callbacks().as_ref()[0].refany,
1641            df.root.get_callbacks().as_ref()[0].refany,
1642            "each render owns its own state allocation"
1643        );
1644    }
1645
1646    // ------------------------------------------------------------------
1647    // on_combobox_toggle
1648    // ------------------------------------------------------------------
1649
1650    #[test]
1651    fn toggle_without_any_layout_result_is_a_noop() {
1652        let mut data = state(&["a"], "", false, 0);
1653        let (update, changes) = run(Env::default(), 0, data.clone(), |r, ci| on_combobox_toggle(r, ci));
1654
1655        assert_eq!(update, Update::DoNothing);
1656        assert!(changes.is_empty());
1657        assert!(!inner_of(&mut data).open, "state must not flip");
1658    }
1659
1660    #[test]
1661    fn toggle_with_a_stale_hit_node_is_a_noop() {
1662        let fx = fixture(&["a"]);
1663        let mut data = state(&["a"], "", false, 0);
1664
1665        let (update, changes) = run(
1666            Env {
1667                styled: Some(fx.styled),
1668                ..Env::default()
1669            },
1670            9_999,
1671            data.clone(),
1672            |r, ci| on_combobox_toggle(r, ci),
1673        );
1674
1675        assert_eq!(update, Update::DoNothing);
1676        assert!(changes.is_empty());
1677        assert!(!inner_of(&mut data).open);
1678    }
1679
1680    #[test]
1681    fn toggle_on_a_node_without_a_next_sibling_does_not_flip_state() {
1682        // The list is the wrapper's LAST child: hitting it finds no sibling, and
1683        // crucially `open` must NOT have been toggled on the way out.
1684        let fx = fixture(&["a"]);
1685        let mut data = state(&["a"], "", true, 0);
1686
1687        let (update, changes) = run(
1688            Env {
1689                styled: Some(fx.styled),
1690                ..Env::default()
1691            },
1692            fx.list,
1693            data.clone(),
1694            |r, ci| on_combobox_toggle(r, ci),
1695        );
1696
1697        assert_eq!(update, Update::DoNothing);
1698        assert!(changes.is_empty());
1699        assert!(inner_of(&mut data).open, "state must be untouched");
1700    }
1701
1702    #[test]
1703    fn toggle_with_a_foreign_payload_does_not_restyle() {
1704        let fx = fixture(&["a"]);
1705        let data = RefAny::new(0xdead_beef_u64);
1706
1707        let (update, changes) = run(
1708            Env {
1709                styled: Some(fx.styled),
1710                ..Env::default()
1711            },
1712            fx.field,
1713            data,
1714            |r, ci| on_combobox_toggle(r, ci),
1715        );
1716
1717        assert_eq!(update, Update::DoNothing);
1718        assert!(
1719            changes.is_empty(),
1720            "a foreign payload must not show or hide the list"
1721        );
1722    }
1723
1724    #[test]
1725    fn toggle_flips_open_and_shows_then_hides_the_list() {
1726        let fx = fixture(&["a", "b"]);
1727        let mut data = state(&["a", "b"], "", false, 0);
1728
1729        // closed -> open
1730        let (update, changes) = run(
1731            Env {
1732                styled: Some(fx.styled.clone()),
1733                ..Env::default()
1734            },
1735            fx.field,
1736            data.clone(),
1737            |r, ci| on_combobox_toggle(r, ci),
1738        );
1739        assert_eq!(update, Update::DoNothing);
1740        assert_eq!(
1741            display_writes(&changes),
1742            alloc::vec![(fx.list, LayoutDisplay::Block)],
1743            "the field's next sibling (the list) is the node that is shown"
1744        );
1745        assert!(inner_of(&mut data).open);
1746
1747        // open -> closed (same payload, so the flip must be stateful)
1748        let (update, changes) = run(
1749            Env {
1750                styled: Some(fx.styled),
1751                ..Env::default()
1752            },
1753            fx.field,
1754            data.clone(),
1755            |r, ci| on_combobox_toggle(r, ci),
1756        );
1757        assert_eq!(update, Update::DoNothing);
1758        assert_eq!(
1759            display_writes(&changes),
1760            alloc::vec![(fx.list, LayoutDisplay::None)]
1761        );
1762        assert!(!inner_of(&mut data).open);
1763    }
1764
1765    // ------------------------------------------------------------------
1766    // on_combobox_text_input / on_combobox_text_input_inner
1767    // ------------------------------------------------------------------
1768
1769    #[test]
1770    fn text_input_without_a_changeset_is_a_noop() {
1771        let fx = fixture(&["a"]);
1772        let mut data = state(&["a"], "abc", false, 0);
1773
1774        let (update, changes) = run(
1775            Env {
1776                styled: Some(fx.styled),
1777                ..Env::default()
1778            },
1779            fx.field,
1780            data.clone(),
1781            |r, ci| on_combobox_text_input(r, ci),
1782        );
1783
1784        assert_eq!(update, Update::DoNothing);
1785        assert!(changes.is_empty());
1786        assert_eq!(inner_of(&mut data).text.as_str(), "abc", "text untouched");
1787    }
1788
1789    #[test]
1790    fn text_input_with_an_empty_insertion_is_a_noop() {
1791        let fx = fixture(&["a"]);
1792        let mut data = state(&["a"], "abc", false, 0);
1793
1794        let (update, changes) = run(
1795            Env {
1796                styled: Some(fx.styled),
1797                changeset: Some(PendingTextEdit {
1798                    node: node(fx.text),
1799                    inserted_text: AzString::from(""),
1800                    old_text: AzString::from("abc"),
1801                }),
1802                ..Env::default()
1803            },
1804            fx.field,
1805            data.clone(),
1806            |r, ci| on_combobox_text_input(r, ci),
1807        );
1808
1809        assert_eq!(update, Update::DoNothing);
1810        assert!(
1811            changes.is_empty(),
1812            "an empty insertion must not re-text the node"
1813        );
1814        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
1815    }
1816
1817    #[test]
1818    fn text_input_on_a_childless_node_is_a_noop() {
1819        // An option row is a leaf: `get_first_child` returns None before any
1820        // state is touched.
1821        let fx = fixture(&["a"]);
1822        let mut data = state(&["a"], "abc", false, 0);
1823
1824        let (update, changes) = run(
1825            Env {
1826                styled: Some(fx.styled),
1827                changeset: Some(PendingTextEdit {
1828                    node: node(fx.text),
1829                    inserted_text: AzString::from("z"),
1830                    old_text: AzString::from("abc"),
1831                }),
1832                ..Env::default()
1833            },
1834            fx.options[0],
1835            data.clone(),
1836            |r, ci| on_combobox_text_input(r, ci),
1837        );
1838
1839        assert_eq!(update, Update::DoNothing);
1840        assert!(changes.is_empty());
1841        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
1842    }
1843
1844    #[test]
1845    fn text_input_appends_to_state_and_retexts_the_field() {
1846        let fx = fixture(&["a"]);
1847        let mut data = state(&["a"], "ab", false, 0);
1848
1849        let (update, changes) = run(
1850            Env {
1851                styled: Some(fx.styled),
1852                changeset: Some(PendingTextEdit {
1853                    node: node(fx.text),
1854                    inserted_text: AzString::from("c"),
1855                    old_text: AzString::from("ab"),
1856                }),
1857                ..Env::default()
1858            },
1859            fx.field,
1860            data.clone(),
1861            |r, ci| on_combobox_text_input(r, ci),
1862        );
1863
1864        assert_eq!(update, Update::DoNothing);
1865        assert_eq!(
1866            text_writes(&changes),
1867            alloc::vec![(fx.text, String::from("abc"))],
1868            "the field's first child is the node that is re-texted"
1869        );
1870        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
1871        // selection/open state is not disturbed by typing
1872        assert!(!inner_of(&mut data).open);
1873        assert_eq!(inner_of(&mut data).selected, 0);
1874    }
1875
1876    #[test]
1877    fn text_input_accumulates_across_keystrokes() {
1878        let fx = fixture(&["a"]);
1879        let mut data = state(&["a"], "", false, 0);
1880
1881        for (i, ch) in ["h", "é", "🌍", "\0"].iter().enumerate() {
1882            let (update, changes) = run(
1883                Env {
1884                    styled: Some(fx.styled.clone()),
1885                    changeset: Some(PendingTextEdit {
1886                        node: node(fx.text),
1887                        inserted_text: AzString::from(*ch),
1888                        old_text: AzString::from(""),
1889                    }),
1890                    ..Env::default()
1891                },
1892                fx.field,
1893                data.clone(),
1894                |r, ci| on_combobox_text_input(r, ci),
1895            );
1896            assert_eq!(update, Update::DoNothing);
1897            assert_eq!(changes.len(), 1, "keystroke {i} produced one text write");
1898        }
1899
1900        assert_eq!(inner_of(&mut data).text.as_str(), "hé🌍\0");
1901    }
1902
1903    #[test]
1904    fn text_input_ignores_the_changesets_own_target_node() {
1905        // Quirk worth pinning: the handler re-texts the HIT node's first child and
1906        // never looks at `changeset.node`. A changeset naming a nonexistent node
1907        // is applied to the field anyway (rather than being dropped or panicking).
1908        let fx = fixture(&["a"]);
1909        let mut data = state(&["a"], "", false, 0);
1910
1911        let (update, changes) = run(
1912            Env {
1913                styled: Some(fx.styled),
1914                changeset: Some(PendingTextEdit {
1915                    // usize::MAX - 1 is the largest index the 1-based
1916                    // `NodeHierarchyItemId` encoding accepts without overflowing.
1917                    node: node(usize::MAX - 1),
1918                    inserted_text: AzString::from("q"),
1919                    old_text: AzString::from("ignored"),
1920                }),
1921                ..Env::default()
1922            },
1923            fx.field,
1924            data.clone(),
1925            |r, ci| on_combobox_text_input(r, ci),
1926        );
1927
1928        assert_eq!(update, Update::DoNothing);
1929        assert_eq!(text_writes(&changes), alloc::vec![(fx.text, String::from("q"))]);
1930        assert_eq!(
1931            inner_of(&mut data).text.as_str(),
1932            "q",
1933            "`old_text` is ignored: the append is against the widget's own state"
1934        );
1935    }
1936
1937    #[test]
1938    fn text_input_with_a_foreign_payload_leaves_the_dom_untouched() {
1939        let fx = fixture(&["a"]);
1940        let data = RefAny::new("not a combobox");
1941
1942        let (update, changes) = run(
1943            Env {
1944                styled: Some(fx.styled),
1945                changeset: Some(PendingTextEdit {
1946                    node: node(fx.text),
1947                    inserted_text: AzString::from("x"),
1948                    old_text: AzString::from(""),
1949                }),
1950                ..Env::default()
1951            },
1952            fx.field,
1953            data,
1954            |r, ci| on_combobox_text_input(r, ci),
1955        );
1956
1957        assert_eq!(update, Update::DoNothing);
1958        assert!(changes.is_empty());
1959    }
1960
1961    #[test]
1962    fn text_input_survives_a_huge_insertion() {
1963        let fx = fixture(&["a"]);
1964        let huge = "y".repeat(100_000);
1965        let mut data = state(&["a"], "", false, 0);
1966
1967        let (update, changes) = run(
1968            Env {
1969                styled: Some(fx.styled),
1970                changeset: Some(PendingTextEdit {
1971                    node: node(fx.text),
1972                    inserted_text: AzString::from(huge.as_str()),
1973                    old_text: AzString::from(""),
1974                }),
1975                ..Env::default()
1976            },
1977            fx.field,
1978            data.clone(),
1979            |r, ci| on_combobox_text_input(r, ci),
1980        );
1981
1982        assert_eq!(update, Update::DoNothing);
1983        assert_eq!(changes.len(), 1);
1984        assert_eq!(inner_of(&mut data).text.as_str().len(), 100_000);
1985    }
1986
1987    #[test]
1988    fn text_input_inner_reports_none_when_it_does_nothing() {
1989        // The `_inner` half distinguishes "nothing to do" (None) from "handled"
1990        // (Some) — the extern wrapper collapses both to DoNothing.
1991        let fx = fixture(&["a"]);
1992
1993        let (out, _) = run(
1994            Env {
1995                styled: Some(fx.styled.clone()),
1996                ..Env::default()
1997            },
1998            fx.field,
1999            state(&["a"], "", false, 0),
2000            on_combobox_text_input_inner,
2001        );
2002        assert_eq!(out, None, "no changeset -> None");
2003
2004        let (out, _) = run(
2005            Env {
2006                styled: Some(fx.styled),
2007                changeset: Some(PendingTextEdit {
2008                    node: node(fx.text),
2009                    inserted_text: AzString::from("k"),
2010                    old_text: AzString::from(""),
2011                }),
2012                ..Env::default()
2013            },
2014            fx.field,
2015            state(&["a"], "", false, 0),
2016            on_combobox_text_input_inner,
2017        );
2018        assert_eq!(out, Some(Update::DoNothing), "handled -> Some");
2019    }
2020
2021    // ------------------------------------------------------------------
2022    // on_combobox_key_down / on_combobox_key_down_inner
2023    // ------------------------------------------------------------------
2024
2025    #[test]
2026    fn key_down_without_a_keycode_is_a_noop() {
2027        let fx = fixture(&["a"]);
2028        let mut data = state(&["a"], "abc", false, 0);
2029
2030        let (update, changes) = run(
2031            Env {
2032                styled: Some(fx.styled),
2033                ..Env::default()
2034            },
2035            fx.field,
2036            data.clone(),
2037            |r, ci| on_combobox_key_down(r, ci),
2038        );
2039
2040        assert_eq!(update, Update::DoNothing);
2041        assert!(changes.is_empty());
2042        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
2043    }
2044
2045    #[test]
2046    fn key_down_ignores_every_key_except_backspace() {
2047        let fx = fixture(&["a"]);
2048
2049        for key in [
2050            VirtualKeyCode::A,
2051            VirtualKeyCode::Return,
2052            VirtualKeyCode::Escape,
2053            VirtualKeyCode::Delete,
2054            VirtualKeyCode::Space,
2055        ] {
2056            let mut data = state(&["a"], "abc", false, 0);
2057            let (update, changes) = run(
2058                Env {
2059                    styled: Some(fx.styled.clone()),
2060                    keycode: Some(key),
2061                    ..Env::default()
2062                },
2063                fx.field,
2064                data.clone(),
2065                |r, ci| on_combobox_key_down(r, ci),
2066            );
2067
2068            assert_eq!(update, Update::DoNothing);
2069            assert!(changes.is_empty(), "{key:?} must not edit the text");
2070            assert_eq!(inner_of(&mut data).text.as_str(), "abc");
2071        }
2072    }
2073
2074    #[test]
2075    fn key_down_backspace_pops_one_char_not_one_byte() {
2076        let fx = fixture(&["a"]);
2077        let mut data = state(&["a"], "hé🌍", false, 0);
2078
2079        let (update, changes) = run(
2080            Env {
2081                styled: Some(fx.styled.clone()),
2082                keycode: Some(VirtualKeyCode::Back),
2083                ..Env::default()
2084            },
2085            fx.field,
2086            data.clone(),
2087            |r, ci| on_combobox_key_down(r, ci),
2088        );
2089
2090        assert_eq!(update, Update::DoNothing);
2091        assert_eq!(
2092            text_writes(&changes),
2093            alloc::vec![(fx.text, String::from("hé"))],
2094            "the 4-byte 🌍 is removed whole — no UTF-8 boundary panic"
2095        );
2096        assert_eq!(inner_of(&mut data).text.as_str(), "hé");
2097
2098        // the two-byte é goes next, still whole
2099        let (_, changes) = run(
2100            Env {
2101                styled: Some(fx.styled),
2102                keycode: Some(VirtualKeyCode::Back),
2103                ..Env::default()
2104            },
2105            fx.field,
2106            data.clone(),
2107            |r, ci| on_combobox_key_down(r, ci),
2108        );
2109        assert_eq!(text_writes(&changes), alloc::vec![(fx.text, String::from("h"))]);
2110        assert_eq!(inner_of(&mut data).text.as_str(), "h");
2111    }
2112
2113    #[test]
2114    fn key_down_backspace_deletes_by_codepoint_not_by_grapheme() {
2115        // Documented consequence of `String::pop`: a combining mark and a ZWJ
2116        // emoji sequence lose ONE codepoint per press, not the whole cluster.
2117        let fx = fixture(&["a"]);
2118        let mut data = state(&["a"], "e\u{0301}", false, 0);
2119
2120        let (_, changes) = run(
2121            Env {
2122                styled: Some(fx.styled.clone()),
2123                keycode: Some(VirtualKeyCode::Back),
2124                ..Env::default()
2125            },
2126            fx.field,
2127            data.clone(),
2128            |r, ci| on_combobox_key_down(r, ci),
2129        );
2130        assert_eq!(text_writes(&changes), alloc::vec![(fx.text, String::from("e"))]);
2131        assert_eq!(inner_of(&mut data).text.as_str(), "e");
2132
2133        // Expected value is derived, not spelled out: the ZWJ joiners the family
2134        // sequence is built from are invisible in source.
2135        let family_str = "👨‍👩‍👧";
2136        let all_but_last: String = {
2137            let mut s = String::from(family_str);
2138            s.pop();
2139            s
2140        };
2141        assert_eq!(
2142            family_str.chars().count(),
2143            5,
2144            "man ZWJ woman ZWJ girl — 5 codepoints, 1 grapheme"
2145        );
2146
2147        let mut family = state(&["a"], family_str, false, 0);
2148        let (_, _) = run(
2149            Env {
2150                styled: Some(fx.styled),
2151                keycode: Some(VirtualKeyCode::Back),
2152                ..Env::default()
2153            },
2154            fx.field,
2155            family.clone(),
2156            |r, ci| on_combobox_key_down(r, ci),
2157        );
2158        let after = inner_of(&mut family).text.as_str().to_string();
2159        assert_eq!(
2160            after, all_but_last,
2161            "only the trailing codepoint is dropped, not the whole cluster"
2162        );
2163        assert_eq!(
2164            after.chars().count(),
2165            4,
2166            "the cluster is still visually broken — one press removed one codepoint"
2167        );
2168    }
2169
2170    #[test]
2171    fn key_down_backspace_on_empty_text_is_safe() {
2172        let fx = fixture(&["a"]);
2173        let mut data = state(&["a"], "", false, 0);
2174
2175        let (update, changes) = run(
2176            Env {
2177                styled: Some(fx.styled),
2178                keycode: Some(VirtualKeyCode::Back),
2179                ..Env::default()
2180            },
2181            fx.field,
2182            data.clone(),
2183            |r, ci| on_combobox_key_down(r, ci),
2184        );
2185
2186        assert_eq!(update, Update::DoNothing);
2187        assert_eq!(
2188            text_writes(&changes),
2189            alloc::vec![(fx.text, String::new())],
2190            "popping an empty string is a no-op write, not a panic"
2191        );
2192        assert!(inner_of(&mut data).text.as_str().is_empty());
2193    }
2194
2195    #[test]
2196    fn key_down_on_a_childless_node_is_a_noop() {
2197        let fx = fixture(&["a"]);
2198        let mut data = state(&["a"], "abc", false, 0);
2199
2200        let (update, changes) = run(
2201            Env {
2202                styled: Some(fx.styled),
2203                keycode: Some(VirtualKeyCode::Back),
2204                ..Env::default()
2205            },
2206            fx.options[0],
2207            data.clone(),
2208            |r, ci| on_combobox_key_down(r, ci),
2209        );
2210
2211        assert_eq!(update, Update::DoNothing);
2212        assert!(changes.is_empty());
2213        assert_eq!(inner_of(&mut data).text.as_str(), "abc");
2214    }
2215
2216    #[test]
2217    fn key_down_with_a_foreign_payload_is_a_noop() {
2218        let fx = fixture(&["a"]);
2219        let data = RefAny::new(7u16);
2220
2221        let (update, changes) = run(
2222            Env {
2223                styled: Some(fx.styled),
2224                keycode: Some(VirtualKeyCode::Back),
2225                ..Env::default()
2226            },
2227            fx.field,
2228            data,
2229            |r, ci| on_combobox_key_down(r, ci),
2230        );
2231
2232        assert_eq!(update, Update::DoNothing);
2233        assert!(changes.is_empty());
2234    }
2235
2236    #[test]
2237    fn key_down_inner_reports_none_when_it_does_nothing() {
2238        let fx = fixture(&["a"]);
2239
2240        let (out, _) = run(
2241            Env {
2242                styled: Some(fx.styled.clone()),
2243                keycode: Some(VirtualKeyCode::A),
2244                ..Env::default()
2245            },
2246            fx.field,
2247            state(&["a"], "abc", false, 0),
2248            on_combobox_key_down_inner,
2249        );
2250        assert_eq!(out, None, "a non-backspace key -> None");
2251
2252        let (out, _) = run(
2253            Env {
2254                styled: Some(fx.styled),
2255                keycode: Some(VirtualKeyCode::Back),
2256                ..Env::default()
2257            },
2258            fx.field,
2259            state(&["a"], "abc", false, 0),
2260            on_combobox_key_down_inner,
2261        );
2262        assert_eq!(out, Some(Update::DoNothing), "backspace -> Some");
2263    }
2264
2265    // ------------------------------------------------------------------
2266    // on_combobox_option_click
2267    // ------------------------------------------------------------------
2268
2269    #[test]
2270    fn option_click_without_any_layout_result_is_a_noop() {
2271        let mut data = state(&["a"], "", true, 0);
2272        let (update, changes) = run(Env::default(), 0, data.clone(), |r, ci| on_combobox_option_click(r, ci));
2273
2274        assert_eq!(update, Update::DoNothing);
2275        assert!(changes.is_empty());
2276        assert!(inner_of(&mut data).open, "state must not change");
2277    }
2278
2279    #[test]
2280    fn option_click_on_a_parentless_node_is_a_noop() {
2281        // The wrapper is the root: it has no parent, so the walk bails out.
2282        let fx = fixture(&["a"]);
2283        let mut data = state(&["a"], "", true, 0);
2284
2285        let (update, changes) = run(
2286            Env {
2287                styled: Some(fx.styled),
2288                ..Env::default()
2289            },
2290            fx.wrapper,
2291            data.clone(),
2292            |r, ci| on_combobox_option_click(r, ci),
2293        );
2294
2295        assert_eq!(update, Update::DoNothing);
2296        assert!(changes.is_empty());
2297        assert!(inner_of(&mut data).open);
2298    }
2299
2300    #[test]
2301    fn option_click_selects_by_previous_sibling_count() {
2302        let labels = ["zero", "one", "two", "three"];
2303        let fx = fixture(&labels);
2304
2305        for (i, label) in labels.iter().enumerate() {
2306            let mut data = state(&labels, "", true, 999);
2307            let (update, changes) = run(
2308                Env {
2309                    styled: Some(fx.styled.clone()),
2310                    ..Env::default()
2311                },
2312                fx.options[i],
2313                data.clone(),
2314                |r, ci| on_combobox_option_click(r, ci),
2315            );
2316
2317            assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
2318
2319            let inner = inner_of(&mut data);
2320            assert_eq!(inner.selected, i, "index = number of previous siblings");
2321            assert_eq!(inner.text.as_str(), *label, "the field takes the label");
2322            assert!(!inner.open, "selecting closes the list");
2323
2324            assert_eq!(
2325                text_writes(&changes),
2326                alloc::vec![(fx.text, String::from(*label))]
2327            );
2328            assert_eq!(
2329                display_writes(&changes),
2330                alloc::vec![(fx.list, LayoutDisplay::None)]
2331            );
2332        }
2333    }
2334
2335    #[test]
2336    fn option_click_index_walk_scales_to_a_long_list() {
2337        // The index is derived by walking previous siblings one at a time; make
2338        // sure a long list terminates and lands on the right (last) index.
2339        let labels: Vec<String> = (0..200).map(|i| alloc::format!("item{i}")).collect();
2340        let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
2341        let fx = fixture(&refs);
2342        let mut data = state(&refs, "", true, 0);
2343
2344        let (update, _) = run(
2345            Env {
2346                styled: Some(fx.styled),
2347                ..Env::default()
2348            },
2349            fx.options[199],
2350            data.clone(),
2351            |r, ci| on_combobox_option_click(r, ci),
2352        );
2353
2354        assert_eq!(update, Update::DoNothing);
2355        let inner = inner_of(&mut data);
2356        assert_eq!(inner.selected, 199);
2357        assert_eq!(inner.text.as_str(), "item199");
2358    }
2359
2360    #[test]
2361    fn option_click_with_an_out_of_range_index_changes_nothing() {
2362        // The rendered list has 3 rows but the payload only knows 1 item — the
2363        // `items.get(index)` miss must abort BEFORE any state or DOM write.
2364        let fx = fixture(&["a", "b", "c"]);
2365        let mut data = state(&["only"], "keep", true, 42);
2366
2367        let (update, changes) = run(
2368            Env {
2369                styled: Some(fx.styled),
2370                ..Env::default()
2371            },
2372            fx.options[2],
2373            data.clone(),
2374            |r, ci| on_combobox_option_click(r, ci),
2375        );
2376
2377        assert_eq!(update, Update::DoNothing);
2378        assert!(changes.is_empty(), "no partial write may escape");
2379        let inner = inner_of(&mut data);
2380        assert_eq!(inner.selected, 42, "selected must not move");
2381        assert_eq!(inner.text.as_str(), "keep");
2382        assert!(inner.open, "the list must not be closed either");
2383    }
2384
2385    #[test]
2386    fn option_click_with_an_empty_item_list_changes_nothing() {
2387        // Same miss, taken from the other side: a DOM with rows, a payload with
2388        // no items at all.
2389        let fx = fixture(&["a"]);
2390        let mut data = state(&[], "keep", true, 0);
2391
2392        let (update, changes) = run(
2393            Env {
2394                styled: Some(fx.styled),
2395                ..Env::default()
2396            },
2397            fx.options[0],
2398            data.clone(),
2399            |r, ci| on_combobox_option_click(r, ci),
2400        );
2401
2402        assert_eq!(update, Update::DoNothing);
2403        assert!(changes.is_empty());
2404        assert_eq!(inner_of(&mut data).text.as_str(), "keep");
2405    }
2406
2407    #[test]
2408    fn option_click_with_a_foreign_payload_is_a_noop() {
2409        let fx = fixture(&["a"]);
2410        let data = RefAny::new(1u8);
2411
2412        let (update, changes) = run(
2413            Env {
2414                styled: Some(fx.styled),
2415                ..Env::default()
2416            },
2417            fx.options[0],
2418            data,
2419            |r, ci| on_combobox_option_click(r, ci),
2420        );
2421
2422        assert_eq!(update, Update::DoNothing);
2423        assert!(changes.is_empty());
2424    }
2425
2426    #[test]
2427    fn option_click_selects_labels_with_nul_and_emoji_verbatim() {
2428        let labels = ["a\0b", "👨‍👩‍👧‍👦", ""];
2429        let fx = fixture(&labels);
2430
2431        for (i, label) in labels.iter().enumerate() {
2432            let mut data = state(&labels, "", true, 0);
2433            let (_, changes) = run(
2434                Env {
2435                    styled: Some(fx.styled.clone()),
2436                    ..Env::default()
2437                },
2438                fx.options[i],
2439                data.clone(),
2440                |r, ci| on_combobox_option_click(r, ci),
2441            );
2442
2443            assert_eq!(inner_of(&mut data).text.as_str(), *label);
2444            assert_eq!(
2445                text_writes(&changes),
2446                alloc::vec![(fx.text, String::from(*label))]
2447            );
2448        }
2449    }
2450
2451    #[test]
2452    fn option_click_invokes_the_user_callback_and_propagates_its_update() {
2453        let fx = fixture(&["a", "b"]);
2454        let mut log = RefAny::new(SelectLog { calls: Vec::new() });
2455        let data = RefAny::new(ComboBoxStateWrapper {
2456            inner: ComboBoxState {
2457                open: true,
2458                selected: 0,
2459                text: AzString::from(""),
2460            },
2461            items: sv(&["a", "b"]),
2462            on_select: Some(ComboBoxOnSelect {
2463                callback: on_select_cb(record_select),
2464                refany: log.clone(),
2465            })
2466            .into(),
2467        });
2468
2469        let (update, changes) = run(
2470            Env {
2471                styled: Some(fx.styled),
2472                ..Env::default()
2473            },
2474            fx.options[1],
2475            data,
2476            |r, ci| on_combobox_option_click(r, ci),
2477        );
2478
2479        // the user's return value wins over the internal DoNothing
2480        assert_eq!(update, Update::RefreshDom);
2481        // ...and the field/list are still updated, even though the user ran
2482        assert_eq!(
2483            text_writes(&changes),
2484            alloc::vec![(fx.text, String::from("b"))]
2485        );
2486        assert_eq!(
2487            display_writes(&changes),
2488            alloc::vec![(fx.list, LayoutDisplay::None)]
2489        );
2490
2491        let logged = log
2492            .downcast_ref::<SelectLog>()
2493            .expect("log payload survived");
2494        assert_eq!(logged.calls.len(), 1);
2495        assert_eq!(logged.calls[0].selected, 1, "the callback sees the NEW index");
2496        assert_eq!(logged.calls[0].text.as_str(), "b", "...and the NEW text");
2497        assert!(!logged.calls[0].open, "...and an already-closed list");
2498    }
2499
2500    #[test]
2501    fn option_click_holds_the_state_borrow_across_the_user_callback() {
2502        // The handler invokes `on_select` while its own `downcast_mut` guard is
2503        // still alive, so a re-entrant borrow of the shared state from inside the
2504        // user callback is REFUSED (returns None) rather than aliasing or
2505        // deadlocking. Pinning this documents the constraint on user callbacks.
2506        let fx = fixture(&["a"]);
2507        let data = RefAny::new(ComboBoxStateWrapper {
2508            inner: ComboBoxState::default(),
2509            items: sv(&["a"]),
2510            on_select: Some(ComboBoxOnSelect {
2511                callback: on_select_cb(probe_reborrow),
2512                refany: RefAny::new(0u8),
2513            })
2514            .into(),
2515        });
2516
2517        SHARED_ALIAS.with(|a| *a.borrow_mut() = Some(data.clone()));
2518        REBORROW_REFUSED.with(|c| c.set(None));
2519
2520        let (update, _) = run(
2521            Env {
2522                styled: Some(fx.styled),
2523                ..Env::default()
2524            },
2525            fx.options[0],
2526            data,
2527            |r, ci| on_combobox_option_click(r, ci),
2528        );
2529
2530        assert_eq!(update, Update::DoNothing);
2531        assert_eq!(
2532            REBORROW_REFUSED.with(|c| c.get()),
2533            Some(true),
2534            "a re-entrant downcast_mut must fail cleanly, not alias or hang"
2535        );
2536
2537        SHARED_ALIAS.with(|a| *a.borrow_mut() = None);
2538    }
2539}