Another project
1use core::time::Duration;
2
3use crate::a11y::{AccessNode, Role};
4use crate::frame::{FrameCtx, InteractDeclaration};
5use crate::hit_test::{Interaction, Sense, ZLayer};
6use crate::input::{FrameInstant, KeyCode, KeyEvent, ModifierMask, NamedKey};
7use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
8use crate::strings::StringKey;
9use crate::theme::{Border, Step12, StrokeWidth};
10use crate::widget_id::{WidgetId, WidgetKey};
11
12use super::keys::{TakeKey, take_key};
13use super::paint::{GlyphMark, LabelText, WidgetPaint};
14use super::visuals::push_focus_ring;
15
16const TYPEAHEAD_RESET: Duration = Duration::from_millis(750);
17
18#[derive(Clone, Debug, PartialEq)]
19pub struct DropdownItem<T: Clone + PartialEq> {
20 pub value: T,
21 pub label: StringKey,
22}
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
25pub struct DropdownState {
26 pub open: bool,
27 pub highlighted: Option<usize>,
28 pub filter: String,
29 pub typeahead_last: Option<FrameInstant>,
30 pub focus_seated: bool,
31 pub last_hovered: Option<usize>,
32}
33
34impl DropdownState {
35 #[must_use]
36 pub fn closed() -> Self {
37 Self::default()
38 }
39
40 fn clear_typeahead(&mut self) {
41 self.filter.clear();
42 self.typeahead_last = None;
43 }
44}
45
46pub struct Dropdown<'state, T: Clone + PartialEq> {
47 pub id: WidgetId,
48 pub trigger_rect: LayoutRect,
49 pub item_height: LayoutPx,
50 pub items: Vec<DropdownItem<T>>,
51 pub selected: Option<T>,
52 pub placeholder: StringKey,
53 pub state: &'state mut DropdownState,
54 pub disabled: bool,
55}
56
57impl<'state, T: Clone + PartialEq> Dropdown<'state, T> {
58 #[must_use]
59 pub fn new(
60 id: WidgetId,
61 trigger_rect: LayoutRect,
62 item_height: LayoutPx,
63 items: Vec<DropdownItem<T>>,
64 selected: Option<T>,
65 placeholder: StringKey,
66 state: &'state mut DropdownState,
67 ) -> Self {
68 Self {
69 id,
70 trigger_rect,
71 item_height,
72 items,
73 selected,
74 placeholder,
75 state,
76 disabled: false,
77 }
78 }
79
80 #[must_use]
81 pub fn disabled(self, disabled: bool) -> Self {
82 Self { disabled, ..self }
83 }
84}
85
86fn popup_rect_below(trigger: LayoutRect, item_height: LayoutPx, count: usize) -> LayoutRect {
87 #[allow(clippy::cast_precision_loss, reason = "dropdown item counts are small")]
88 let count_f32 = count as f32;
89 LayoutRect::new(
90 LayoutPos::new(
91 trigger.origin.x,
92 LayoutPx::new(trigger.origin.y.value() + trigger.size.height.value()),
93 ),
94 LayoutSize::new(
95 trigger.size.width,
96 LayoutPx::new(item_height.value() * count_f32),
97 ),
98 )
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub struct DropdownResponse<T: Clone + PartialEq> {
103 pub interaction: Interaction,
104 pub selected: Option<T>,
105 pub changed: bool,
106 pub paint: Vec<WidgetPaint>,
107}
108
109#[must_use]
110pub fn show_dropdown<T: Clone + PartialEq>(
111 ctx: &mut FrameCtx<'_>,
112 dropdown: Dropdown<'_, T>,
113) -> DropdownResponse<T> {
114 let Dropdown {
115 id,
116 trigger_rect,
117 item_height,
118 items,
119 selected: initial_selected,
120 placeholder,
121 state,
122 disabled,
123 } = dropdown;
124 let interactive = !disabled;
125 let popup_rect = popup_rect_below(trigger_rect, item_height, items.len());
126 let trigger_interaction = ctx.interact(
127 InteractDeclaration::new(id, trigger_rect, Sense::INTERACTIVE)
128 .focusable(interactive)
129 .disabled(!interactive)
130 .active(state.open)
131 .a11y(
132 AccessNode::new(Role::ComboBox)
133 .with_label(placeholder)
134 .with_disabled(!interactive)
135 .with_expanded(state.open),
136 ),
137 );
138 let live_focused = ctx.is_focused(id);
139 if state.open && state.focus_seated && !live_focused {
140 state.open = false;
141 }
142 if state.open && pointer_pressed_outside(ctx.input, trigger_rect, popup_rect) {
143 state.open = false;
144 }
145 let pointer_toggled = interactive && trigger_interaction.click();
146 let keyboard_opened = interactive && live_focused && !state.open && {
147 take_key(
148 ctx.input,
149 &[
150 TakeKey::named(NamedKey::Enter),
151 TakeKey::named(NamedKey::Space),
152 TakeKey::named(NamedKey::ArrowDown),
153 ],
154 )
155 .is_some()
156 };
157 if pointer_toggled {
158 state.open = !state.open;
159 } else if keyboard_opened {
160 state.open = true;
161 }
162 if state.open && live_focused {
163 state.focus_seated = true;
164 }
165 if state.open && state.highlighted.is_none() {
166 state.highlighted = current_index(&items, initial_selected.as_ref())
167 .or_else(|| (!items.is_empty()).then_some(0));
168 }
169 if !state.open {
170 state.clear_typeahead();
171 state.focus_seated = false;
172 state.last_hovered = None;
173 }
174 let mut selected = initial_selected.clone();
175 let mut changed = false;
176 let mut paint = trigger_paint(
177 ctx,
178 trigger_rect,
179 disabled,
180 trigger_label(&items, initial_selected.as_ref(), placeholder),
181 trigger_interaction,
182 live_focused,
183 );
184 if state.open && interactive {
185 render_open_popup(
186 ctx,
187 PopupInputs {
188 id,
189 popup_rect,
190 item_height,
191 label: placeholder,
192 items: &items,
193 initial_selected: initial_selected.as_ref(),
194 state,
195 live_focused,
196 },
197 &mut selected,
198 &mut changed,
199 &mut paint,
200 );
201 }
202 DropdownResponse {
203 interaction: trigger_interaction,
204 selected,
205 changed,
206 paint,
207 }
208}
209
210struct PopupInputs<'a, T: Clone + PartialEq> {
211 id: WidgetId,
212 popup_rect: LayoutRect,
213 item_height: LayoutPx,
214 label: StringKey,
215 items: &'a [DropdownItem<T>],
216 initial_selected: Option<&'a T>,
217 state: &'a mut DropdownState,
218 live_focused: bool,
219}
220
221fn render_open_popup<T: Clone + PartialEq>(
222 ctx: &mut FrameCtx<'_>,
223 inputs: PopupInputs<'_, T>,
224 selected: &mut Option<T>,
225 changed: &mut bool,
226 paint: &mut Vec<WidgetPaint>,
227) {
228 let PopupInputs {
229 id,
230 popup_rect,
231 item_height,
232 label,
233 items,
234 initial_selected,
235 state,
236 live_focused,
237 } = inputs;
238 if live_focused {
239 if let Some(action) = take_keyboard_action(ctx) {
240 let page_size = visible_item_count(popup_rect, item_height);
241 apply_keyboard_action(action, items, page_size, state, selected, changed);
242 }
243 typeahead_step(ctx, items, state);
244 }
245 ctx.a11y.push(
246 id.child(WidgetKey::new("popup")),
247 popup_rect,
248 AccessNode::new(Role::ListBox).with_label(label),
249 );
250 let mut popup_paints: Vec<WidgetPaint> = Vec::new();
251 popup_paints.push(WidgetPaint::Surface {
252 rect: popup_rect,
253 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1),
254 border: Some(Border {
255 width: StrokeWidth::HAIRLINE,
256 color: ctx.theme().colors.neutral.step(Step12::BORDER),
257 }),
258 radius: ctx.theme().radius.sm,
259 elevation: Some(ctx.theme().elevation.level1),
260 });
261 let prev_hovered = state.last_hovered;
262 let mut current_hovered: Option<usize> = None;
263 items.iter().enumerate().for_each(|(index, item)| {
264 let item_rect = item_rect(popup_rect, item_height, index);
265 let item_id = id.child_indexed(WidgetKey::new("item"), index as u64);
266 let item_interaction = ctx.interact(
267 InteractDeclaration::new(item_id, item_rect, Sense::INTERACTIVE)
268 .at_z(ZLayer::POPUP)
269 .active(Some(&item.value) == initial_selected)
270 .a11y(
271 AccessNode::new(Role::ListBoxOption)
272 .with_label(item.label)
273 .with_selected(Some(&item.value) == initial_selected),
274 ),
275 );
276 if item_interaction.hover() {
277 current_hovered = Some(index);
278 if prev_hovered != Some(index) {
279 state.highlighted = Some(index);
280 }
281 }
282 if item_interaction.click() {
283 let next = Some(item.value.clone());
284 *changed = next != *selected;
285 *selected = next;
286 state.open = false;
287 }
288 let highlighted = state.highlighted == Some(index);
289 popup_paints.push(WidgetPaint::Surface {
290 rect: item_rect,
291 fill: if highlighted {
292 ctx.theme().colors.accent.step(Step12::HOVER_BG)
293 } else if Some(&item.value) == initial_selected {
294 ctx.theme().colors.neutral.step(Step12::SELECTED_BG)
295 } else {
296 ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1)
297 },
298 border: None,
299 radius: ctx.theme().radius.none,
300 elevation: None,
301 });
302 popup_paints.push(WidgetPaint::Label {
303 rect: item_rect,
304 text: LabelText::Key(item.label),
305 color: ctx.theme().colors.text_primary(),
306 role: ctx.theme().typography.body,
307 });
308 });
309 state.last_hovered = current_hovered;
310 paint.push(WidgetPaint::Popup {
311 paints: popup_paints,
312 });
313}
314
315fn pointer_pressed_outside(
316 input: &crate::input::InputSnapshot,
317 trigger: LayoutRect,
318 popup: LayoutRect,
319) -> bool {
320 if input.buttons_pressed.is_empty() {
321 return false;
322 }
323 let Some(sample) = input.pointer else {
324 return false;
325 };
326 !trigger.contains(sample.position) && !popup.contains(sample.position)
327}
328
329fn apply_keyboard_action<T: Clone + PartialEq>(
330 action: KeyboardAction,
331 items: &[DropdownItem<T>],
332 page_size: usize,
333 state: &mut DropdownState,
334 selected: &mut Option<T>,
335 changed: &mut bool,
336) {
337 state.clear_typeahead();
338 let count = items.len();
339 match action {
340 KeyboardAction::Up => {
341 state.highlighted = move_highlight(state.highlighted, count, HighlightStep::Prev);
342 }
343 KeyboardAction::Down => {
344 state.highlighted = move_highlight(state.highlighted, count, HighlightStep::Next);
345 }
346 KeyboardAction::PageUp => {
347 state.highlighted = page_highlight(state.highlighted, count, page_size, false);
348 }
349 KeyboardAction::PageDown => {
350 state.highlighted = page_highlight(state.highlighted, count, page_size, true);
351 }
352 KeyboardAction::Home => {
353 if count > 0 {
354 state.highlighted = Some(0);
355 }
356 }
357 KeyboardAction::End => {
358 if count > 0 {
359 state.highlighted = Some(count - 1);
360 }
361 }
362 KeyboardAction::Enter => {
363 if let Some(idx) = state.highlighted
364 && let Some(item) = items.get(idx)
365 {
366 let next = Some(item.value.clone());
367 *changed = next != *selected;
368 *selected = next;
369 state.open = false;
370 }
371 }
372 KeyboardAction::Escape => {
373 state.open = false;
374 }
375 }
376}
377
378fn page_highlight(
379 current: Option<usize>,
380 count: usize,
381 page: usize,
382 forward: bool,
383) -> Option<usize> {
384 if count == 0 {
385 return None;
386 }
387 let step = page.max(1);
388 let idx = current.unwrap_or(if forward { 0 } else { count - 1 });
389 let next = if forward {
390 (idx + step).min(count - 1)
391 } else {
392 idx.saturating_sub(step)
393 };
394 Some(next)
395}
396
397fn trigger_label<T: Clone + PartialEq>(
398 items: &[DropdownItem<T>],
399 selected: Option<&T>,
400 placeholder: StringKey,
401) -> StringKey {
402 selected
403 .and_then(|sel| items.iter().find(|i| &i.value == sel).map(|i| i.label))
404 .unwrap_or(placeholder)
405}
406
407fn typeahead_step<T: Clone + PartialEq>(
408 ctx: &mut FrameCtx<'_>,
409 items: &[DropdownItem<T>],
410 state: &mut DropdownState,
411) {
412 let now = ctx.input.frame;
413 let pending = core::mem::take(&mut ctx.input.keys_pressed);
414 let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| {
415 let Some(typed) = printable_char(event) else {
416 acc.push(event);
417 return acc;
418 };
419 if state
420 .typeahead_last
421 .is_some_and(|t| now.since(t) > TYPEAHEAD_RESET)
422 {
423 state.filter.clear();
424 }
425 state.filter.push(typed);
426 state.typeahead_last = Some(now);
427 let needle = state.filter.to_lowercase();
428 let matched = items.iter().position(|item| {
429 ctx.strings.contains(item.label)
430 && ctx
431 .strings
432 .resolve(item.label)
433 .to_lowercase()
434 .starts_with(&needle)
435 });
436 if let Some(idx) = matched {
437 state.highlighted = Some(idx);
438 }
439 acc
440 });
441 ctx.input.keys_pressed = unconsumed;
442}
443
444fn printable_char(event: KeyEvent) -> Option<char> {
445 if event.modifiers != ModifierMask::NONE && event.modifiers != ModifierMask::SHIFT {
446 return None;
447 }
448 match event.code {
449 KeyCode::Char(c) => Some(c.get()),
450 KeyCode::Named(NamedKey::Space) => Some(' '),
451 KeyCode::Named(_) => None,
452 }
453}
454
455fn visible_item_count(popup: LayoutRect, item_height: LayoutPx) -> usize {
456 let h = item_height.value();
457 if h <= 0.0 {
458 return 0;
459 }
460 #[allow(
461 clippy::cast_possible_truncation,
462 clippy::cast_sign_loss,
463 reason = "popup heights yield small non-negative usize"
464 )]
465 let count = (popup.size.height.value() / h).floor() as usize;
466 count
467}
468
469fn item_rect(popup: LayoutRect, item_height: LayoutPx, index: usize) -> LayoutRect {
470 #[allow(
471 clippy::cast_precision_loss,
472 reason = "dropdown indices fit in f32 mantissa"
473 )]
474 let index_f32 = index as f32;
475 let y = popup.origin.y.value() + index_f32 * item_height.value();
476 LayoutRect::new(
477 LayoutPos::new(popup.origin.x, LayoutPx::new(y)),
478 LayoutSize::new(popup.size.width, item_height),
479 )
480}
481
482fn current_index<T: Clone + PartialEq>(
483 items: &[DropdownItem<T>],
484 selected: Option<&T>,
485) -> Option<usize> {
486 selected.and_then(|sel| items.iter().position(|item| &item.value == sel))
487}
488
489#[derive(Copy, Clone, Debug)]
490enum HighlightStep {
491 Prev,
492 Next,
493}
494
495fn move_highlight(current: Option<usize>, count: usize, step: HighlightStep) -> Option<usize> {
496 if count == 0 {
497 return None;
498 }
499 let next = match (current, step) {
500 (None, HighlightStep::Next) => 0,
501 (None, HighlightStep::Prev) => count - 1,
502 (Some(idx), HighlightStep::Next) => (idx + 1) % count,
503 (Some(idx), HighlightStep::Prev) => (idx + count - 1) % count,
504 };
505 Some(next)
506}
507
508#[derive(Copy, Clone, Debug)]
509enum KeyboardAction {
510 Up,
511 Down,
512 PageUp,
513 PageDown,
514 Home,
515 End,
516 Enter,
517 Escape,
518}
519
520fn take_keyboard_action(ctx: &mut FrameCtx<'_>) -> Option<KeyboardAction> {
521 let event = take_key(
522 ctx.input,
523 &[
524 TakeKey::named(NamedKey::ArrowUp),
525 TakeKey::named(NamedKey::ArrowDown),
526 TakeKey::named(NamedKey::PageUp),
527 TakeKey::named(NamedKey::PageDown),
528 TakeKey::named(NamedKey::Home),
529 TakeKey::named(NamedKey::End),
530 TakeKey::named(NamedKey::Enter),
531 TakeKey::named(NamedKey::Escape),
532 ],
533 )?;
534 let action = match event.code {
535 KeyCode::Named(NamedKey::ArrowUp) => KeyboardAction::Up,
536 KeyCode::Named(NamedKey::ArrowDown) => KeyboardAction::Down,
537 KeyCode::Named(NamedKey::PageUp) => KeyboardAction::PageUp,
538 KeyCode::Named(NamedKey::PageDown) => KeyboardAction::PageDown,
539 KeyCode::Named(NamedKey::Home) => KeyboardAction::Home,
540 KeyCode::Named(NamedKey::End) => KeyboardAction::End,
541 KeyCode::Named(NamedKey::Enter) => KeyboardAction::Enter,
542 KeyCode::Named(NamedKey::Escape) => KeyboardAction::Escape,
543 _ => unreachable!("take_key only returns the listed candidates"),
544 };
545 Some(action)
546}
547
548fn trigger_paint(
549 ctx: &FrameCtx<'_>,
550 trigger_rect: LayoutRect,
551 disabled: bool,
552 label_text: StringKey,
553 interaction: Interaction,
554 live_focused: bool,
555) -> Vec<WidgetPaint> {
556 let neutral = ctx.theme().colors.neutral;
557 let radius = ctx.theme().radius.sm;
558 let hovered = interaction.hover();
559 let pressed = interaction.pressed();
560 let fill = if disabled {
561 neutral.step(Step12::SUBTLE_BG)
562 } else if pressed {
563 neutral.step(Step12::SELECTED_BG)
564 } else if hovered {
565 neutral.step(Step12::HOVER_BG)
566 } else {
567 neutral.step(Step12::ELEMENT_BG)
568 };
569 let border = Border {
570 width: StrokeWidth::HAIRLINE,
571 color: neutral.step(if hovered {
572 Step12::HOVER_BORDER
573 } else {
574 Step12::BORDER
575 }),
576 };
577 let mut paint = vec![
578 WidgetPaint::Surface {
579 rect: trigger_rect,
580 fill,
581 border: Some(border),
582 radius,
583 elevation: None,
584 },
585 WidgetPaint::Label {
586 rect: trigger_rect,
587 text: LabelText::Key(label_text),
588 color: if disabled {
589 ctx.theme().colors.text_disabled()
590 } else {
591 ctx.theme().colors.text_primary()
592 },
593 role: ctx.theme().typography.body,
594 },
595 WidgetPaint::Mark {
596 rect: trigger_rect,
597 kind: GlyphMark::Chevron,
598 color: ctx.theme().colors.text_secondary(),
599 },
600 ];
601 push_focus_ring(ctx, &mut paint, trigger_rect, radius, live_focused);
602 paint
603}
604
605#[cfg(test)]
606mod tests {
607 use std::sync::Arc;
608
609 use super::{Dropdown, DropdownItem, DropdownState, show_dropdown};
610 use crate::focus::FocusManager;
611 use crate::frame::FrameCtx;
612 use crate::hit_test::{HitFrame, HitState, resolve};
613 use crate::hotkey::HotkeyTable;
614 use crate::input::{
615 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey,
616 PointerButton, PointerButtonMask, PointerSample,
617 };
618 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
619 use crate::strings::StringKey;
620 use crate::strings::StringTable;
621 use crate::theme::Theme;
622 use crate::widget_id::{WidgetId, WidgetKey};
623
624 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
625 enum Choice {
626 Apple,
627 Banana,
628 Cherry,
629 }
630
631 const PLACEHOLDER: StringKey = StringKey::new("dropdown.choose");
632
633 fn dropdown_id() -> WidgetId {
634 WidgetId::ROOT.child(WidgetKey::new("dropdown"))
635 }
636
637 fn trigger_rect() -> LayoutRect {
638 LayoutRect::new(
639 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
640 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(28.0)),
641 )
642 }
643
644 const ITEM_HEIGHT: LayoutPx = LayoutPx::new(24.0);
645
646 fn items() -> Vec<DropdownItem<Choice>> {
647 vec![
648 DropdownItem {
649 value: Choice::Apple,
650 label: StringKey::new("fruit.apple"),
651 },
652 DropdownItem {
653 value: Choice::Banana,
654 label: StringKey::new("fruit.banana"),
655 },
656 DropdownItem {
657 value: Choice::Cherry,
658 label: StringKey::new("fruit.cherry"),
659 },
660 ]
661 }
662
663 fn render_with(
664 state: &mut DropdownState,
665 selected: Option<Choice>,
666 focus: &mut FocusManager,
667 snap: &mut InputSnapshot,
668 prev: &HitState,
669 ) -> (super::DropdownResponse<Choice>, HitState, HitFrame) {
670 let theme = Arc::new(Theme::light());
671 let table = HotkeyTable::new();
672 let mut hits = HitFrame::new();
673 let widget = Dropdown::new(
674 dropdown_id(),
675 trigger_rect(),
676 ITEM_HEIGHT,
677 items(),
678 selected,
679 PLACEHOLDER,
680 state,
681 );
682 let response = {
683 let mut shaper = bone_text::Shaper::new();
684 let mut a11y = crate::a11y::AccessTreeBuilder::new();
685 let mut ctx = FrameCtx::new(
686 theme,
687 snap,
688 focus,
689 &table,
690 StringTable::empty(),
691 &mut hits,
692 prev,
693 &mut a11y,
694 &mut shaper,
695 );
696 show_dropdown(&mut ctx, widget)
697 };
698 let next_state = resolve(prev, &hits, snap, focus.focused());
699 (response, next_state, hits)
700 }
701
702 fn pointer_press_at(pos: LayoutPos) -> InputSnapshot {
703 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
704 s.pointer = Some(PointerSample::new(pos));
705 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
706 s
707 }
708
709 fn pointer_release_at(pos: LayoutPos) -> InputSnapshot {
710 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
711 s.pointer = Some(PointerSample::new(pos));
712 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
713 s
714 }
715
716 fn pointer_idle_at(pos: LayoutPos) -> InputSnapshot {
717 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
718 s.pointer = Some(PointerSample::new(pos));
719 s
720 }
721
722 fn click_at(
723 state: &mut DropdownState,
724 selected: &mut Option<Choice>,
725 focus: &mut FocusManager,
726 prev: &mut HitState,
727 pos: LayoutPos,
728 ) {
729 [
730 pointer_press_at(pos),
731 pointer_release_at(pos),
732 pointer_idle_at(pos),
733 ]
734 .into_iter()
735 .for_each(|mut snap| {
736 let (response, next, _) = render_with(state, *selected, focus, &mut snap, prev);
737 if response.changed {
738 *selected = response.selected;
739 }
740 *prev = next;
741 });
742 }
743
744 fn focused_setup() -> FocusManager {
745 let mut focus = FocusManager::new();
746 focus.register_focusable(dropdown_id());
747 focus.request_focus(dropdown_id());
748 focus.end_frame();
749 focus
750 }
751
752 fn open_state(highlighted: Option<usize>) -> DropdownState {
753 DropdownState {
754 open: true,
755 highlighted,
756 filter: String::new(),
757 typeahead_last: None,
758 focus_seated: true,
759 last_hovered: None,
760 }
761 }
762
763 #[test]
764 fn click_trigger_opens_dropdown() {
765 let mut state = DropdownState::closed();
766 let mut selected = None;
767 let mut focus = FocusManager::new();
768 let mut prev = HitState::new();
769 click_at(
770 &mut state,
771 &mut selected,
772 &mut focus,
773 &mut prev,
774 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)),
775 );
776 assert!(state.open, "first click opens");
777 click_at(
778 &mut state,
779 &mut selected,
780 &mut focus,
781 &mut prev,
782 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)),
783 );
784 assert!(!state.open, "second click closes");
785 }
786
787 #[test]
788 fn click_item_selects_and_closes() {
789 let mut state = open_state(Some(0));
790 let mut selected = None;
791 let mut focus = focused_setup();
792 let mut prev = HitState::new();
793 let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 12.0));
794 click_at(&mut state, &mut selected, &mut focus, &mut prev, item_pos);
795 assert_eq!(selected, Some(Choice::Apple));
796 assert!(!state.open);
797 }
798
799 #[test]
800 fn arrow_down_then_enter_picks_next_item() {
801 let mut state = open_state(Some(0));
802 let selected = None;
803 let mut focus = focused_setup();
804 let prev = HitState::new();
805 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
806 snap.keys_pressed.push(KeyEvent::new(
807 KeyCode::Named(NamedKey::ArrowDown),
808 ModifierMask::NONE,
809 ));
810 let (_, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev);
811 assert_eq!(state.highlighted, Some(1));
812
813 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
814 snap.keys_pressed.push(KeyEvent::new(
815 KeyCode::Named(NamedKey::Enter),
816 ModifierMask::NONE,
817 ));
818 let (response, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev);
819 assert_eq!(response.selected, Some(Choice::Banana));
820 assert!(!state.open);
821 }
822
823 #[test]
824 fn escape_closes_without_changing_selection() {
825 let mut state = open_state(Some(2));
826 let selected = Some(Choice::Apple);
827 let mut focus = focused_setup();
828 let prev = HitState::new();
829 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
830 snap.keys_pressed.push(KeyEvent::new(
831 KeyCode::Named(NamedKey::Escape),
832 ModifierMask::NONE,
833 ));
834 let (response, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev);
835 assert_eq!(response.selected, selected);
836 assert!(!response.changed);
837 assert!(!state.open);
838 }
839
840 #[test]
841 fn home_end_jumps_to_extremes() {
842 let mut state = open_state(Some(1));
843 let mut focus = focused_setup();
844 let prev = HitState::new();
845 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
846 snap.keys_pressed.push(KeyEvent::new(
847 KeyCode::Named(NamedKey::Home),
848 ModifierMask::NONE,
849 ));
850 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
851 assert_eq!(state.highlighted, Some(0));
852
853 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
854 snap.keys_pressed.push(KeyEvent::new(
855 KeyCode::Named(NamedKey::End),
856 ModifierMask::NONE,
857 ));
858 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
859 assert_eq!(state.highlighted, Some(2));
860 }
861
862 #[test]
863 fn arrow_keys_wrap_around_list() {
864 let mut state = open_state(Some(2));
865 let mut focus = focused_setup();
866 let prev = HitState::new();
867 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
868 snap.keys_pressed.push(KeyEvent::new(
869 KeyCode::Named(NamedKey::ArrowDown),
870 ModifierMask::NONE,
871 ));
872 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
873 assert_eq!(state.highlighted, Some(0));
874 }
875
876 #[test]
877 fn focused_closed_dropdown_opens_on_enter() {
878 let mut state = DropdownState::closed();
879 let mut focus = focused_setup();
880 let prev = HitState::new();
881 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
882 snap.keys_pressed.push(KeyEvent::new(
883 KeyCode::Named(NamedKey::Enter),
884 ModifierMask::NONE,
885 ));
886 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
887 assert!(state.open, "Enter on focused trigger opens");
888 assert!(snap.keys_pressed.is_empty(), "Enter consumed");
889 }
890
891 #[test]
892 fn focused_closed_dropdown_opens_on_arrow_down() {
893 let mut state = DropdownState::closed();
894 let mut focus = focused_setup();
895 let prev = HitState::new();
896 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
897 snap.keys_pressed.push(KeyEvent::new(
898 KeyCode::Named(NamedKey::ArrowDown),
899 ModifierMask::NONE,
900 ));
901 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
902 assert!(state.open);
903 }
904
905 #[test]
906 fn open_dropdown_auto_closes_when_focus_leaves() {
907 let mut state = open_state(Some(0));
908 let mut focus = FocusManager::new();
909 let prev = HitState::new();
910 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
911 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
912 assert!(!state.open, "open + seated + no live focus closes");
913 }
914
915 fn typeahead_render_with(
916 state: &mut DropdownState,
917 focus: &mut FocusManager,
918 snap: &mut InputSnapshot,
919 prev: &HitState,
920 strings: &StringTable,
921 ) -> super::DropdownResponse<Choice> {
922 let theme = Arc::new(Theme::light());
923 let table = HotkeyTable::new();
924 let mut hits = HitFrame::new();
925 let widget = Dropdown::new(
926 dropdown_id(),
927 trigger_rect(),
928 ITEM_HEIGHT,
929 items(),
930 None,
931 PLACEHOLDER,
932 state,
933 );
934 let mut shaper = bone_text::Shaper::new();
935 let mut a11y = crate::a11y::AccessTreeBuilder::new();
936 let mut ctx = FrameCtx::new(
937 theme,
938 snap,
939 focus,
940 &table,
941 strings,
942 &mut hits,
943 prev,
944 &mut a11y,
945 &mut shaper,
946 );
947 show_dropdown(&mut ctx, widget)
948 }
949
950 #[test]
951 fn typeahead_highlights_first_matching_label() {
952 let mut state = open_state(Some(0));
953 let mut focus = focused_setup();
954 let prev = HitState::new();
955 let strings = StringTable::from_entries([
956 (StringKey::new("fruit.apple"), "Apple".to_owned()),
957 (StringKey::new("fruit.banana"), "Banana".to_owned()),
958 (StringKey::new("fruit.cherry"), "Cherry".to_owned()),
959 ]);
960 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
961 snap.keys_pressed.push(KeyEvent::new(
962 KeyCode::Char(KeyChar::from_char('b')),
963 ModifierMask::NONE,
964 ));
965 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
966 assert_eq!(state.highlighted, Some(1));
967 assert_eq!(state.filter, "b");
968 }
969
970 #[test]
971 fn typeahead_filter_clears_on_navigation() {
972 let mut state = open_state(Some(0));
973 state.filter = "ban".to_owned();
974 let mut focus = focused_setup();
975 let prev = HitState::new();
976 let strings = StringTable::new();
977 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
978 snap.keys_pressed.push(KeyEvent::new(
979 KeyCode::Named(NamedKey::ArrowDown),
980 ModifierMask::NONE,
981 ));
982 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
983 assert!(state.filter.is_empty(), "navigation clears filter");
984 }
985
986 #[test]
987 fn pointer_hover_over_item_updates_highlight() {
988 let mut state = open_state(Some(0));
989 let mut focus = focused_setup();
990 let mut prev = HitState::new();
991 let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 24.0 + 12.0));
992 (0..2).for_each(|_| {
993 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
994 snap.pointer = Some(PointerSample::new(item_pos));
995 let (_, next, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
996 prev = next;
997 });
998 assert_eq!(
999 state.highlighted,
1000 Some(1),
1001 "hover over second item bumps highlight",
1002 );
1003 }
1004
1005 #[test]
1006 fn keyboard_nav_holds_against_stationary_pointer() {
1007 let mut state = open_state(Some(0));
1008 let mut focus = focused_setup();
1009 let mut prev = HitState::new();
1010 let item1_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 24.0 + 12.0));
1011 (0..2).for_each(|_| {
1012 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1013 snap.pointer = Some(PointerSample::new(item1_pos));
1014 let (_, next, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
1015 prev = next;
1016 });
1017 assert_eq!(state.highlighted, Some(1), "hover seeded highlight");
1018
1019 let mut nav_snap = InputSnapshot::idle(FrameInstant::ZERO);
1020 nav_snap.pointer = Some(PointerSample::new(item1_pos));
1021 nav_snap.keys_pressed.push(KeyEvent::new(
1022 KeyCode::Named(NamedKey::ArrowDown),
1023 ModifierMask::NONE,
1024 ));
1025 let (_, after_nav, _) = render_with(&mut state, None, &mut focus, &mut nav_snap, &prev);
1026 prev = after_nav;
1027 assert_eq!(
1028 state.highlighted,
1029 Some(2),
1030 "ArrowDown wins because hovered item did not change",
1031 );
1032
1033 let mut idle_snap = InputSnapshot::idle(FrameInstant::ZERO);
1034 idle_snap.pointer = Some(PointerSample::new(item1_pos));
1035 let _ = render_with(&mut state, None, &mut focus, &mut idle_snap, &prev);
1036 assert_eq!(
1037 state.highlighted,
1038 Some(2),
1039 "stationary pointer cannot reclaim highlight from keyboard",
1040 );
1041 }
1042
1043 #[test]
1044 fn outside_pointer_press_closes_open_popup() {
1045 let mut state = open_state(Some(0));
1046 let mut focus = focused_setup();
1047 let prev = HitState::new();
1048 let mut snap = pointer_press_at(LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(500.0)));
1049 let (_, _, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
1050 assert!(!state.open, "click outside closes");
1051 }
1052
1053 #[test]
1054 fn outside_press_with_no_pointer_does_not_close() {
1055 let mut state = open_state(Some(0));
1056 let mut focus = focused_setup();
1057 let prev = HitState::new();
1058 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1059 snap.buttons_pressed =
1060 crate::input::PointerButtonMask::just(crate::input::PointerButton::Primary);
1061 let (_, _, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
1062 assert!(state.open, "no pointer sample = no outside-click signal");
1063 }
1064
1065 #[test]
1066 fn typeahead_resets_after_quiet_window() {
1067 let mut state = open_state(Some(0));
1068 let mut focus = focused_setup();
1069 let prev = HitState::new();
1070 let strings = StringTable::from_entries([
1071 (StringKey::new("fruit.apple"), "Apple".to_owned()),
1072 (StringKey::new("fruit.banana"), "Banana".to_owned()),
1073 (StringKey::new("fruit.cherry"), "Cherry".to_owned()),
1074 ]);
1075 let mut snap = InputSnapshot::idle(FrameInstant::from_duration(
1076 core::time::Duration::from_millis(100),
1077 ));
1078 snap.keys_pressed.push(KeyEvent::new(
1079 KeyCode::Char(KeyChar::from_char('a')),
1080 ModifierMask::NONE,
1081 ));
1082 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
1083 assert_eq!(state.filter, "a");
1084
1085 let mut snap2 = InputSnapshot::idle(FrameInstant::from_duration(
1086 core::time::Duration::from_millis(100 + 800),
1087 ));
1088 snap2.keys_pressed.push(KeyEvent::new(
1089 KeyCode::Char(KeyChar::from_char('b')),
1090 ModifierMask::NONE,
1091 ));
1092 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap2, &prev, &strings);
1093 assert_eq!(state.filter, "b", "quiet window reset filter to fresh char");
1094 assert_eq!(state.highlighted, Some(1));
1095 }
1096
1097 #[test]
1098 fn page_down_jumps_by_visible_count() {
1099 let mut state = open_state(Some(0));
1100 let mut focus = focused_setup();
1101 let prev = HitState::new();
1102 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1103 snap.keys_pressed.push(KeyEvent::new(
1104 KeyCode::Named(NamedKey::PageDown),
1105 ModifierMask::NONE,
1106 ));
1107 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
1108 assert_eq!(
1109 state.highlighted,
1110 Some(2),
1111 "PageDown jumps to last when within visible count"
1112 );
1113 }
1114
1115 #[test]
1116 fn page_up_clamps_at_first() {
1117 let mut state = open_state(Some(1));
1118 let mut focus = focused_setup();
1119 let prev = HitState::new();
1120 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1121 snap.keys_pressed.push(KeyEvent::new(
1122 KeyCode::Named(NamedKey::PageUp),
1123 ModifierMask::NONE,
1124 ));
1125 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
1126 assert_eq!(state.highlighted, Some(0));
1127 }
1128
1129 #[test]
1130 fn popup_rect_below_anchors_under_trigger_and_sizes_to_items() {
1131 let trigger = trigger_rect();
1132 let popup = super::popup_rect_below(trigger, ITEM_HEIGHT, 4);
1133 assert_eq!(popup.origin.x, trigger.origin.x);
1134 assert!(
1135 (popup.origin.y.value() - (trigger.origin.y.value() + trigger.size.height.value()))
1136 .abs()
1137 < 1e-6,
1138 "popup y must sit at trigger bottom"
1139 );
1140 assert_eq!(popup.size.width, trigger.size.width);
1141 assert!(
1142 (popup.size.height.value() - 96.0).abs() < 1e-6,
1143 "4 items × 24px = 96px"
1144 );
1145 }
1146
1147 #[test]
1148 fn typeahead_skips_unresolved_keys() {
1149 let mut state = open_state(Some(0));
1150 let mut focus = focused_setup();
1151 let prev = HitState::new();
1152 let strings = StringTable::new();
1153 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
1154 snap.keys_pressed.push(KeyEvent::new(
1155 KeyCode::Char(KeyChar::from_char('f')),
1156 ModifierMask::NONE,
1157 ));
1158 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
1159 assert_eq!(
1160 state.highlighted,
1161 Some(0),
1162 "highlight unchanged when no string entries match",
1163 );
1164 }
1165
1166 #[test]
1167 fn disabled_dropdown_swallows_clicks() {
1168 let mut state = DropdownState::closed();
1169 let mut focus = FocusManager::new();
1170 let mut prev = HitState::new();
1171 let theme = Arc::new(Theme::light());
1172 let table = HotkeyTable::new();
1173 [
1174 pointer_press_at(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0))),
1175 pointer_release_at(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0))),
1176 ]
1177 .into_iter()
1178 .for_each(|mut snap| {
1179 let mut hits = HitFrame::new();
1180 let widget = Dropdown::new(
1181 dropdown_id(),
1182 trigger_rect(),
1183 ITEM_HEIGHT,
1184 items(),
1185 None,
1186 PLACEHOLDER,
1187 &mut state,
1188 )
1189 .disabled(true);
1190 let _ = {
1191 let mut shaper = bone_text::Shaper::new();
1192 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1193 let mut ctx = FrameCtx::new(
1194 theme.clone(),
1195 &mut snap,
1196 &mut focus,
1197 &table,
1198 StringTable::empty(),
1199 &mut hits,
1200 &prev,
1201 &mut a11y,
1202 &mut shaper,
1203 );
1204 show_dropdown(&mut ctx, widget)
1205 };
1206 prev = resolve(&prev, &hits, &snap, focus.focused());
1207 });
1208 assert!(!state.open);
1209 }
1210
1211 #[test]
1212 fn open_popup_wins_hit_over_sibling_pushed_after_at_base_z() {
1213 use crate::frame::InteractDeclaration;
1214 use crate::hit_test::{HitItem, Sense, ZLayer};
1215 let mut state = open_state(Some(0));
1216 let mut focus = focused_setup();
1217 let prev = HitState::new();
1218 let theme = Arc::new(Theme::light());
1219 let table = HotkeyTable::new();
1220 let mut hits = HitFrame::new();
1221 let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 12.0));
1222 let mut snap = pointer_press_at(item_pos);
1223 let widget = Dropdown::new(
1224 dropdown_id(),
1225 trigger_rect(),
1226 ITEM_HEIGHT,
1227 items(),
1228 None,
1229 PLACEHOLDER,
1230 &mut state,
1231 );
1232 let sibling_id = WidgetId::ROOT.child(WidgetKey::new("sibling_under_popup"));
1233 {
1234 let mut shaper = bone_text::Shaper::new();
1235 let mut a11y = crate::a11y::AccessTreeBuilder::new();
1236 let mut ctx = FrameCtx::new(
1237 theme,
1238 &mut snap,
1239 &mut focus,
1240 &table,
1241 StringTable::empty(),
1242 &mut hits,
1243 &prev,
1244 &mut a11y,
1245 &mut shaper,
1246 );
1247 let _ = show_dropdown(&mut ctx, widget);
1248 let _ = ctx.interact(InteractDeclaration::new(
1249 sibling_id,
1250 LayoutRect::new(
1251 LayoutPos::new(LayoutPx::ZERO, LayoutPx::new(28.0)),
1252 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(72.0)),
1253 ),
1254 Sense::INTERACTIVE,
1255 ));
1256 }
1257 let item_at_idx_0 = dropdown_id().child_indexed(WidgetKey::new("item"), 0);
1258 let Some(popup_item) = hits.items().iter().find(|h| h.id == item_at_idx_0) else {
1259 panic!("popup item must be registered in hit frame");
1260 };
1261 assert_eq!(popup_item.z, ZLayer::POPUP);
1262 let next = resolve(&prev, &hits, &snap, focus.focused());
1263 let hit_id = hits
1264 .items()
1265 .iter()
1266 .filter(|item: &&HitItem| {
1267 item.sense.contains(Sense::HOVER) && item.rect.contains(item_pos)
1268 })
1269 .max_by_key(|item: &&HitItem| item.z)
1270 .map(|item: &HitItem| item.id);
1271 assert_eq!(
1272 hit_id,
1273 Some(item_at_idx_0),
1274 "popup item must beat later sibling at base z when both contain pointer",
1275 );
1276 assert!(
1277 next.interaction(item_at_idx_0).pressed(),
1278 "press routes to popup item, not sibling",
1279 );
1280 assert!(
1281 !next.interaction(sibling_id).pressed(),
1282 "sibling did not consume press despite later push order",
1283 );
1284 }
1285}