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