1use 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
89const LIST_OFFSET_Y: isize = 28;
93const MIN_WIDTH: isize = 160;
95const RADIUS: isize = 4;
96const ARROW_FONT_SIZE_PX: isize = 18;
97
98const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
100const BORDER_COLOR: ColorU = ColorU { r: 172, g: 172, b: 172, a: 255 }; const BORDER_FOCUS: ColorU = ColorU { r: 66, g: 134, b: 244, a: 255 }; const TEXT_COLOR: ColorU = ColorU { r: 51, g: 51, b: 51, a: 255 }; const OPTION_HOVER_BG: ColorU = ColorU { r: 234, g: 244, b: 252, a: 255 }; const 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
113pub 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#[derive(Debug, Clone, PartialEq, Eq)]
139#[repr(C)]
140pub struct ComboBox {
141 pub combo_state: ComboBoxStateWrapper,
144 pub placeholder: AzString,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149#[repr(C)]
150pub struct ComboBoxStateWrapper {
151 pub inner: ComboBoxState,
153 pub items: StringVec,
155 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#[derive(Debug, Clone, PartialEq, Eq)]
172#[repr(C)]
173pub struct ComboBoxState {
174 pub open: bool,
176 pub selected: usize,
178 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
192static 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
207static 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 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 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 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 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
306static 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
315static 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
324fn 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 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 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
398static 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 #[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 #[must_use] pub fn create() -> Self {
434 Self::new(StringVec::from_const_slice(&[]))
435 }
436
437 #[inline]
439 pub const fn set_selected(&mut self, selected: usize) {
440 self.combo_state.inner.selected = selected;
441 }
442
443 #[inline]
445 #[must_use] pub const fn with_selected(mut self, selected: usize) -> Self {
446 self.set_selected(selected);
447 self
448 }
449
450 #[inline]
452 pub fn set_text(&mut self, text: AzString) {
453 self.combo_state.inner.text = text;
454 }
455
456 #[inline]
458 #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
459 self.set_text(text);
460 self
461 }
462
463 #[inline]
465 pub fn set_placeholder(&mut self, placeholder: AzString) {
466 self.placeholder = placeholder;
467 }
468
469 #[inline]
471 #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
472 self.set_placeholder(placeholder);
473 self
474 }
475
476 #[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 #[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 #[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 #[must_use] pub fn dom(self) -> Dom {
508 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 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 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 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 .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
616extern "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 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
644extern "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
673extern "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
700extern "C" fn on_combobox_option_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
706 let option = info.get_hit_node();
707
708 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 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#[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 fn sv(items: &[&str]) -> StringVec {
809 StringVec::from_vec(items.iter().map(|s| AzString::from(*s)).collect())
810 }
811
812 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 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 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 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 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 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 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 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 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 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 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 #[derive(Default)]
961 struct Env {
962 styled: Option<StyledDom>,
963 changeset: Option<PendingTextEdit>,
964 keycode: Option<VirtualKeyCode>,
965 }
966
967 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: ¤t_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 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 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 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 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 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 static SHARED_ALIAS: RefCell<Option<RefAny>> = const { RefCell::new(None) };
1108 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 #[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 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 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 #[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 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 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 assert_eq!(ComboBox::create(), ComboBox::create());
1263 }
1264
1265 #[test]
1270 fn set_selected_stores_every_index_verbatim() {
1271 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 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 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 #[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 #[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 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 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 let bare = ComboBox::create().dom();
1397 let (field, _) = parts(&bare);
1398 assert_eq!(text_of(&field.children.as_ref()[0]), Some(""));
1399 }
1400
1401 #[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 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 assert_eq!(built.combo_state.items.as_ref().len(), 1);
1434 assert_eq!(built.combo_state.inner, ComboBoxState::default());
1435 }
1436
1437 #[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 let second = combo.swap_with_default();
1461 assert_eq!(second, ComboBox::create());
1462 assert_eq!(combo, ComboBox::create());
1463 }
1464
1465 #[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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 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 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 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 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 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 #[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 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 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 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 #[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 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 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 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 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 assert_eq!(update, Update::RefreshDom);
2481 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 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}