1use crate::{ActiveTheme, ButtonVariant, IconName, button, icon, input};
3use gpui_kit::base::{Combobox, ElementExt as _, Popup, Select, input::InputState};
4use gpui_kit::rems;
5use gpui_kit::{
6 App, Context, ElementId, Entity, FocusHandle, Focusable, SharedString, Window, div, prelude::*,
7};
8
9#[derive(Clone, Debug)]
10pub struct ChoiceItem {
11 pub value: SharedString,
12 pub label: SharedString,
13 pub disabled: bool,
14}
15impl ChoiceItem {
16 pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
17 Self {
18 value: value.into(),
19 label: label.into(),
20 disabled: false,
21 }
22 }
23 pub fn disabled(mut self, disabled: bool) -> Self {
24 self.disabled = disabled;
25 self
26 }
27}
28
29pub struct ChoiceState {
32 items: Vec<ChoiceItem>,
33 selected: Option<usize>,
34 cursor: Option<usize>,
35 open: bool,
36 disabled: bool,
37 label: SharedString,
38 placeholder: SharedString,
39 trigger_focus: FocusHandle,
40 popup_focus: FocusHandle,
41 query: Entity<InputState>,
42 searchable: bool,
43 scroll: gpui_kit::ScrollHandle,
44 popup_width: gpui_kit::Pixels,
45}
46impl ChoiceState {
47 pub fn new(items: Vec<ChoiceItem>, window: &mut Window, cx: &mut Context<Self>) -> Self {
48 let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search options…"));
49 cx.subscribe(
50 &query,
51 |this, _, event: &gpui_kit::base::input::InputEvent, cx| {
52 if !matches!(event, gpui_kit::base::input::InputEvent::Change) {
53 return;
54 }
55 this.cursor = if this.query.read(cx).value().trim().is_empty() {
56 this.selected
57 .or_else(|| this.items.iter().position(|item| !item.disabled))
58 } else {
59 this.visible(cx)
60 .into_iter()
61 .find(|&i| !this.items[i].disabled)
62 };
63 this.scroll.set_offset(gpui_kit::Point::default());
64 cx.notify();
65 },
66 )
67 .detach();
68 Self {
69 items,
70 selected: None,
71 cursor: None,
72 open: false,
73 disabled: false,
74 label: "Choose an option".into(),
75 placeholder: "Choose an option…".into(),
76 trigger_focus: cx.focus_handle(),
77 popup_focus: cx.focus_handle(),
78 query,
79 searchable: false,
80 scroll: gpui_kit::ScrollHandle::new(),
81 popup_width: rems(17.5).to_pixels(window.rem_size()),
82 }
83 }
84 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
85 self.label = label.into();
86 self
87 }
88 pub fn placeholder(mut self, label: impl Into<SharedString>) -> Self {
89 self.placeholder = label.into();
90 self
91 }
92 pub fn disabled(mut self, disabled: bool) -> Self {
93 self.disabled = disabled;
94 self
95 }
96 pub fn default_selected(mut self, index: usize) -> Self {
97 self.selected = self
98 .items
99 .get(index)
100 .filter(|item| !item.disabled)
101 .map(|_| index);
102 self
103 }
104 pub fn selected(&self) -> Option<&ChoiceItem> {
105 self.selected.and_then(|i| self.items.get(i))
106 }
107 pub fn is_open(&self) -> bool {
108 self.open
109 }
110 pub fn set_selected(&mut self, index: Option<usize>, cx: &mut Context<Self>) {
111 self.selected = index.filter(|&i| self.items.get(i).is_some_and(|item| !item.disabled));
112 cx.notify();
113 }
114 fn visible(&self, cx: &App) -> Vec<usize> {
115 let query = if self.searchable {
116 self.query.read(cx).value().trim().to_lowercase()
117 } else {
118 String::new()
119 };
120 self.items
121 .iter()
122 .enumerate()
123 .filter_map(|(i, item)| item.label.to_lowercase().contains(&query).then_some(i))
124 .collect()
125 }
126 fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
127 if self.disabled || self.open == open {
128 return;
129 }
130 self.open = open;
131 if open {
132 if self.searchable {
133 let style = cx.macos().input_style();
134 self.query.update(cx, |query, cx| {
135 query.set_editor_style(style);
139 query.set_value("", window, cx);
140 });
141 }
142 self.cursor = self
143 .selected
144 .or_else(|| self.items.iter().position(|item| !item.disabled));
145 if self.searchable {
146 self.query.update(cx, |query, cx| query.focus(window, cx));
147 } else {
148 self.popup_focus.focus(window, cx);
149 }
150 } else {
151 self.trigger_focus.focus(window, cx);
152 }
153 cx.notify();
154 window.refresh();
155 }
156 fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
157 if let Some(index) = self.cursor.filter(|&i| !self.items[i].disabled) {
158 self.selected = Some(index);
159 self.set_open(false, window, cx);
160 }
161 }
162 fn handle_key(&mut self, key: &str, window: &mut Window, cx: &mut Context<Self>) {
163 match key {
164 "up" | "down" | "home" | "end" => {
165 if !self.open {
166 self.set_open(true, window, cx);
167 } else {
168 self.navigate(key, cx);
169 }
170 }
171 "enter" | "space" => {
172 if self.open {
173 self.commit(window, cx);
174 } else {
175 self.set_open(true, window, cx);
176 }
177 }
178 "escape" => self.set_open(false, window, cx),
179 "tab" | "shift-tab" => {
180 self.set_open(false, window, cx);
181 if key == "tab" {
182 window.focus_next(cx);
183 } else {
184 window.focus_prev(cx);
185 }
186 }
187 _ => {}
188 }
189 window.refresh();
190 }
191 fn navigate(&mut self, key: &str, cx: &mut Context<Self>) {
192 let visible = self.visible(cx);
193 let enabled: Vec<_> = visible
194 .iter()
195 .copied()
196 .filter(|&i| !self.items[i].disabled)
197 .collect();
198 let position = self
199 .cursor
200 .and_then(|i| enabled.iter().position(|&v| v == i));
201 self.cursor = if enabled.is_empty() {
202 None
203 } else {
204 Some(
205 enabled[match key {
206 "home" => 0,
207 "end" => enabled.len() - 1,
208 "up" => position.map_or(enabled.len() - 1, |i| {
209 (i + enabled.len() - 1) % enabled.len()
210 }),
211 _ => position.map_or(0, |i| (i + 1) % enabled.len()),
212 }],
213 )
214 };
215 if let Some(row) = self
216 .cursor
217 .and_then(|i| visible.iter().position(|&v| v == i))
218 {
219 self.scroll.scroll_to_item(row);
220 }
221 cx.notify();
222 }
223}
224impl Focusable for ChoiceState {
225 fn focus_handle(&self, _: &App) -> FocusHandle {
226 self.trigger_focus.clone()
227 }
228}
229
230pub fn select(
232 id: impl Into<ElementId>,
233 state: &Entity<ChoiceState>,
234 window: &mut Window,
235 cx: &mut App,
236) -> Select {
237 let id = id.into();
238 let body = presentation(id.clone(), state, false, window, cx);
239 let choice = state.read(cx);
240 let target = state.clone();
241 Select::new(id)
242 .open(choice.open)
243 .disabled(choice.disabled)
244 .accessibility_label(choice.label.clone())
245 .focus_handle(&choice.trigger_focus)
246 .on_open_change(move |open, window, cx| {
247 target.update(cx, |state, cx| state.set_open(open, window, cx))
248 })
249 .w_full()
250 .child(body)
251}
252
253pub fn combobox(
255 id: impl Into<ElementId>,
256 state: &Entity<ChoiceState>,
257 window: &mut Window,
258 cx: &mut App,
259) -> Combobox {
260 let id = id.into();
261 let body = presentation(id.clone(), state, true, window, cx);
262 let choice = state.read(cx);
263 let target = state.clone();
264 Combobox::new(id)
265 .open(choice.open)
266 .disabled(choice.disabled)
267 .focus_handle(&choice.trigger_focus)
268 .on_open_change(move |open, window, cx| {
269 target.update(cx, |state, cx| state.set_open(open, window, cx))
270 })
271 .w_full()
272 .child(body)
273}
274
275fn presentation(
276 id: ElementId,
277 state: &Entity<ChoiceState>,
278 searchable: bool,
279 window: &mut Window,
280 cx: &mut App,
281) -> impl IntoElement + use<> {
282 state.update(cx, |state, _| state.searchable = searchable);
283 let t = cx.macos().clone();
284 let window_active = window.is_window_active();
285 let choice = state.read(cx);
286 let label = choice
287 .selected()
288 .map(|item| item.label.clone())
289 .unwrap_or_else(|| choice.placeholder.clone());
290 let open = choice.open;
291 let disabled = choice.disabled;
292 let trigger_focus = choice.trigger_focus.clone();
293 let accessible_label = choice.label.clone();
294 let target = state.clone();
295 let trigger = button("choice-trigger", "", ButtonVariant::Secondary, cx)
296 .focusable(false)
297 .disabled(disabled)
298 .w_full()
299 .justify_start()
300 .accessibility_label(accessible_label.clone())
301 .border_color(t.foreground.opacity(0.))
302 .bg(t.control_fill())
303 .child(div().flex_1().min_w_0().text_ellipsis().child(label))
304 .child(
305 icon(if searchable {
306 IconName::ChevronDown
307 } else {
308 IconName::ChevronsUpDown
309 })
310 .size(rems(0.875)),
311 )
312 .on_click(move |_, window, cx| {
313 target.update(cx, |state, cx| state.set_open(!state.open, window, cx))
314 });
315 let measure = state.clone();
316 let trigger = div()
317 .w_full()
318 .child(trigger)
319 .on_prepaint(move |bounds, window, cx| {
320 measure.update(cx, |state, _| {
321 if state.popup_width != bounds.size.width {
322 state.popup_width = bounds.size.width;
323 window.request_animation_frame();
324 }
325 });
326 });
327 let mut popup = Popup::new((id, "popup"), trigger).w_full();
328 if open {
329 let render = state.clone();
330 let close = state.clone();
331 popup = popup
332 .offset(rems(0.25).to_pixels(window.rem_size()))
333 .content_factory(move |window, cx| choice_content(&render, window, cx))
334 .on_dismiss(move |window, cx| {
335 close.update(cx, |state, cx| {
336 state.set_open(false, window, cx);
337 state.trigger_focus.focus(window, cx);
338 })
339 });
340 }
341 let root = div()
342 .id("choice-keys")
343 .aria_label(accessible_label)
344 .when(!disabled, |root| {
345 root.track_focus(&trigger_focus.tab_stop(true))
346 })
347 .w_full()
348 .rounded(rems(0.375))
349 .when(window_active, |root| {
350 root.focus_visible(|s| s.shadow(t.focus_shadow()))
351 });
352 choice_keys(root, state, searchable).child(popup)
353}
354
355fn choice_keys(
356 mut root: gpui_kit::Stateful<gpui_kit::Div>,
357 state: &Entity<ChoiceState>,
358 searchable: bool,
359) -> gpui_kit::Stateful<gpui_kit::Div> {
360 macro_rules! action {
361 ($ty:ty, $key:expr) => {{
362 let target = state.clone();
363 root = root.capture_action(move |_: &$ty, window, cx| {
364 if target.read(cx).disabled {
365 return;
366 }
367 cx.stop_propagation();
368 target.update(cx, |state, cx| state.handle_key($key, window, cx));
369 });
370 }};
371 }
372 action!(gpui_kit::base::actions::Confirm, "enter");
373 action!(gpui_kit::base::actions::Cancel, "escape");
374 action!(gpui_kit::base::actions::SelectUp, "up");
375 action!(gpui_kit::base::actions::SelectDown, "down");
376 action!(gpui_kit::base::input::Enter, "enter");
377 action!(gpui_kit::base::input::Escape, "escape");
378 action!(gpui_kit::base::input::MoveUp, "up");
379 action!(gpui_kit::base::input::MoveDown, "down");
380 action!(gpui_kit::base::input::IndentInline, "tab");
381 action!(gpui_kit::base::input::OutdentInline, "shift-tab");
382 let target = state.clone();
383 root.capture_key_down(move |event, window, cx| {
384 if target.read(cx).disabled {
385 return;
386 }
387 let key = event.keystroke.key.as_str();
388 let modifiers = event.keystroke.modifiers;
389 let navigation =
390 if modifiers.control && !modifiers.alt && !modifiers.platform && !modifiers.shift {
391 match key {
392 "j" | "n" => Some("down"),
393 "k" | "p" => Some("up"),
394 _ => None,
395 }
396 } else if !searchable && !modifiers.modified() {
397 match key {
398 "j" => Some("down"),
399 "k" => Some("up"),
400 _ => None,
401 }
402 } else {
403 None
404 };
405 if let Some(key) = navigation.filter(|_| target.read(cx).open) {
406 target.update(cx, |state, cx| state.handle_key(key, window, cx));
407 } else if key == "tab" {
408 let backward = event.keystroke.modifiers.shift;
409 target.update(cx, |state, cx| {
410 state.handle_key(if backward { "shift-tab" } else { "tab" }, window, cx)
411 });
412 } else if !searchable
413 && !event.keystroke.modifiers.modified()
414 && matches!(key, "home" | "end" | "space")
415 {
416 target.update(cx, |state, cx| state.handle_key(key, window, cx));
417 } else {
418 return;
419 }
420 cx.stop_propagation();
421 })
422}
423
424fn choice_content(
425 state: &Entity<ChoiceState>,
426 window: &mut Window,
427 cx: &mut App,
428) -> gpui_kit::Stateful<gpui_kit::Div> {
429 let t = cx.macos().clone();
430 let choice = state.read(cx);
431 let searchable = choice.searchable;
432 let focus = choice.popup_focus.clone();
433 let query = choice.query.clone();
434 let visible = choice.visible(cx);
435 let cursor = choice.cursor;
436 let selected = choice.selected;
437 let items = choice.items.clone();
438 let scroll = choice.scroll.clone();
439 let popup_width = choice.popup_width;
440 let window_active = window.is_window_active();
441 let cursor_fill = crate::controls::control_selection(&t, window_active);
442 let cursor_text = crate::controls::control_mark(&t, window_active);
443 if crate::native::is_glass_popup(window) {
444 let first = window.use_keyed_state("choice-native-focus", cx, |_, _| true);
445 if *first.read(cx) {
446 first.update(cx, |first, _| *first = false);
447 if searchable {
448 query.update(cx, |query, cx| query.focus(window, cx));
449 } else {
450 focus.focus(window, cx);
451 }
452 }
453 }
454 let close = state.clone();
455 let confirm = state.clone();
456 let mut content = div()
457 .id("choice-popup")
458 .debug_selector(|| "macos-choice-popup".into())
459 .track_focus(&focus)
460 .w(popup_width)
461 .max_w(gpui_kit::relative(1.))
462 .p(rems(0.375))
463 .border_1()
464 .rounded(rems(0.75))
465 .border_color(t.glass_border())
466 .shadow(t.popover_shadow())
467 .bg(t.surface)
468 .text_color(t.foreground)
469 .font_family(t.font.clone())
470 .text_size(rems(0.8125))
471 .line_height(gpui_kit::relative(1.2))
472 .flex()
473 .flex_col()
474 .gap(rems(0.375))
475 .on_mouse_down_out(move |_, window, cx| {
476 close.update(cx, |state, cx| {
477 state.set_open(false, window, cx);
478 })
479 })
480 .on_action(move |_: &gpui_kit::base::actions::Confirm, window, cx| {
481 cx.stop_propagation();
482 confirm.update(cx, |state, cx| state.commit(window, cx));
483 });
484 if searchable {
485 content = content.child(
486 input("choice-search", &query, window, cx)
487 .h(rems(1.5))
488 .py(rems(0.))
489 .px(rems(0.375)),
490 );
491 }
492 let mut rows = div()
493 .id("choice-options")
494 .role(gpui_kit::Role::ListBox)
495 .max_h(rems(14.))
496 .overflow_y_scroll()
497 .track_scroll(&scroll)
498 .flex()
499 .flex_col();
500 if visible.is_empty() {
501 rows = rows.child(
502 div()
503 .p(rems(0.625))
504 .text_color(t.secondary)
505 .child("No matching options"),
506 );
507 }
508 for index in visible {
509 let target = state.clone();
510 let hover = state.clone();
511 let item = &items[index];
512 rows = rows.child(
513 button(("choice-option", index), "", ButtonVariant::Secondary, cx)
514 .fill_duration(std::time::Duration::ZERO)
515 .accessibility_label(item.label.clone())
516 .debug_selector(move || format!("macos-choice-option-{index}"))
517 .role(gpui_kit::Role::ListBoxOption)
518 .aria_selected(selected == Some(index))
519 .when(cursor == Some(index), |row| row.aria_active_descendant())
520 .focusable(false)
521 .disabled(item.disabled)
522 .w_full()
523 .h(rems(1.5))
524 .py(rems(0.))
525 .px(rems(0.375))
526 .gap(rems(0.375))
527 .justify_start()
528 .rounded(rems(0.375))
529 .border_color(t.foreground.opacity(0.))
530 .shadow(Vec::new())
531 .bg(if cursor == Some(index) {
532 cursor_fill
533 } else {
534 t.foreground.opacity(0.)
535 })
536 .text_color(if cursor == Some(index) {
537 cursor_text
538 } else {
539 t.foreground
540 })
541 .hover(|s| s.bg(cursor_fill).text_color(cursor_text))
542 .active(|s| s.bg(cursor_fill).text_color(cursor_text))
543 .child(
544 div()
545 .flex_1()
546 .min_w_0()
547 .text_ellipsis()
548 .child(item.label.clone()),
549 )
550 .child(
551 div()
552 .w(rems(0.875))
553 .flex_shrink_0()
554 .when(selected == Some(index), |mark| {
555 mark.child(icon(IconName::Check).size(rems(0.875)))
556 }),
557 )
558 .on_hover(move |hovered, window, cx| {
559 if *hovered {
560 hover.update(cx, |state, cx| {
561 if !state.items[index].disabled {
562 state.cursor = Some(index);
563 cx.notify();
564 window.refresh();
565 }
566 });
567 }
568 })
569 .on_click(move |_, window, cx| {
570 target.update(cx, |state, cx| {
571 state.cursor = Some(index);
572 state.commit(window, cx);
573 })
574 }),
575 );
576 }
577 if crate::native::is_glass_popup(window) {
578 content = content
579 .bg(gpui_kit::transparent_black())
580 .border_0()
581 .shadow_none();
582 }
583 choice_keys(content.child(rows), state, searchable)
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use gpui_kit::px;
590 use gpui_kit::{Render, TestAppContext, VisualTestContext};
591 struct Harness {
592 state: Entity<ChoiceState>,
593 searchable: bool,
594 }
595 impl Render for Harness {
596 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
597 crate::focus_scope("root")
598 .size_full()
599 .child(button("before", "Before", ButtonVariant::Secondary, cx))
600 .child(div().w(rems(17.5)).child(if self.searchable {
601 combobox("choice", &self.state, window, cx).into_any_element()
602 } else {
603 select("choice", &self.state, window, cx).into_any_element()
604 }))
605 .child(button("after", "After", ButtonVariant::Secondary, cx))
606 }
607 }
608 fn harness(
609 cx: &mut TestAppContext,
610 searchable: bool,
611 disabled: bool,
612 ) -> (Entity<ChoiceState>, &mut VisualTestContext) {
613 cx.update(crate::init);
614 let (view, cx) = cx.add_window_view(move |window, cx| {
615 let state = cx.new(|cx| {
616 ChoiceState::new(
617 vec![
618 ChoiceItem::new("first", "First"),
619 ChoiceItem::new("disabled", "Disabled").disabled(true),
620 ChoiceItem::new("last", "Last"),
621 ],
622 window,
623 cx,
624 )
625 .default_selected(0)
626 .disabled(disabled)
627 });
628 cx.observe(&state, |_, _, cx| cx.notify()).detach();
629 Harness { state, searchable }
630 });
631 let state = view.read_with(cx, |view, _| view.state.clone());
632 cx.update(|window, cx| {
633 state.read(cx).trigger_focus.clone().focus(window, cx);
634 window.draw(cx).clear(cx);
635 });
636 (state, cx)
637 }
638 #[gpui_kit::test]
639 fn select_skips_disabled_commits_and_restores_focus(cx: &mut TestAppContext) {
640 let (state, cx) = harness(cx, false, false);
641 cx.simulate_keystrokes("down");
642 cx.update(|window, cx| {
643 window.draw(cx).clear(cx);
644 assert!(state.read(cx).open);
645 });
646 cx.simulate_keystrokes("down enter");
647 cx.update(|window, cx| {
648 window.draw(cx).clear(cx);
649 assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "last");
650 assert!(!state.read(cx).open);
651 assert!(state.read(cx).trigger_focus.is_focused(window));
652 });
653 }
654 #[gpui_kit::test]
655 fn select_vim_navigation_skips_disabled_and_wraps(cx: &mut TestAppContext) {
656 let (state, cx) = harness(cx, false, false);
657 cx.simulate_keystrokes("enter j");
658 cx.update(|_, cx| assert_eq!(state.read(cx).cursor, Some(2)));
659 cx.simulate_keystrokes("j");
660 cx.update(|_, cx| assert_eq!(state.read(cx).cursor, Some(0)));
661 cx.simulate_keystrokes("k enter");
662 cx.update(|window, cx| {
663 assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "last");
664 assert!(!state.read(cx).open);
665 assert!(state.read(cx).trigger_focus.is_focused(window));
666 });
667 }
668
669 #[gpui_kit::test]
670 fn combobox_vim_navigation_preserves_search_input(cx: &mut TestAppContext) {
671 let (state, cx) = harness(cx, true, false);
672 cx.simulate_keystrokes("enter ctrl-j");
673 cx.update(|_, cx| assert_eq!(state.read(cx).cursor, Some(2)));
674 cx.simulate_keystrokes("ctrl-k");
675 cx.update(|_, cx| assert_eq!(state.read(cx).cursor, Some(0)));
676 cx.simulate_keystrokes("ctrl-n");
677 cx.update(|_, cx| assert_eq!(state.read(cx).cursor, Some(2)));
678 cx.simulate_keystrokes("ctrl-p");
679 cx.update(|_, cx| assert_eq!(state.read(cx).cursor, Some(0)));
680 cx.simulate_keystrokes("j k");
681 cx.update(|_, cx| {
682 let choice = state.read(cx);
683 assert_eq!(choice.query.read(cx).value().as_ref(), "jk");
684 assert!(choice.visible(cx).is_empty());
685 assert_eq!(choice.cursor, None);
686 });
687 cx.simulate_keystrokes("ctrl-j enter");
688 cx.update(|_, cx| assert!(state.read(cx).open));
689 }
690
691 #[gpui_kit::test]
692 fn combobox_filters_and_empty_result_cannot_commit(cx: &mut TestAppContext) {
693 let (state, cx) = harness(cx, true, false);
694 cx.simulate_keystrokes("enter");
695 cx.update(|window, cx| {
696 window.draw(cx).clear(cx);
697 assert!(state.read(cx).open);
698 });
699 cx.simulate_input("zzzz");
700 cx.update(|window, cx| {
701 window.draw(cx).clear(cx);
702 assert!(state.read(cx).visible(cx).is_empty());
703 });
704 cx.simulate_keystrokes("enter");
705 cx.update(|_, cx| assert!(state.read(cx).open));
706 cx.simulate_keystrokes("escape");
707 cx.update(|window, cx| {
708 window.draw(cx).clear(cx);
709 assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "first");
710 assert!(state.read(cx).trigger_focus.is_focused(window));
711 });
712 cx.simulate_keystrokes("enter");
713 cx.update(|window, cx| {
714 window.draw(cx).clear(cx);
715 });
716 cx.simulate_input("LAST");
717 cx.update(|window, cx| {
718 window.draw(cx).clear(cx);
719 });
720 cx.simulate_keystrokes("enter");
721 cx.update(|_, cx| {
722 assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "last");
723 assert!(!state.read(cx).open);
724 });
725 }
726 #[gpui_kit::test]
727 fn disabled_choice_does_not_open(cx: &mut TestAppContext) {
728 let (state, cx) = harness(cx, false, true);
729 cx.simulate_keystrokes("down enter space");
730 cx.simulate_click(
731 gpui_kit::point(px(10.), px(10.)),
732 gpui_kit::Modifiers::default(),
733 );
734 cx.update(|_, cx| assert!(!state.read(cx).open));
735 }
736 #[gpui_kit::test]
737 fn tab_leaves_combobox_without_focusing_hidden_search(cx: &mut TestAppContext) {
738 let (state, cx) = harness(cx, true, false);
739 cx.simulate_keystrokes("enter");
740 cx.update(|window, cx| {
741 window.draw(cx).clear(cx);
742 });
743 cx.simulate_keystrokes("tab");
744 cx.update(|window, cx| {
745 window.draw(cx).clear(cx);
746 let choice = state.read(cx);
747 assert!(!choice.open);
748 assert!(!choice.trigger_focus.is_focused(window));
749 assert!(
750 !choice.query.read(cx).focus_handle(cx).is_focused(window),
751 "Tab must leave the closed popup's input"
752 );
753 assert!(window.focused(cx).is_some());
754 });
755 cx.simulate_keystrokes("shift-tab");
756 cx.update(|window, cx| assert!(state.read(cx).trigger_focus.is_focused(window)));
757 }
758
759 #[gpui_kit::test]
760 fn pointer_selects_and_outside_click_preserves_value(cx: &mut TestAppContext) {
761 let (state, cx) = harness(cx, false, false);
762 cx.simulate_keystrokes("enter");
763 cx.update(|window, cx| {
764 window.draw(cx).clear(cx);
765 });
766 let row = cx.debug_bounds("macos-choice-option-2").unwrap();
767 cx.simulate_click(row.center(), gpui_kit::Modifiers::default());
768 cx.update(|window, cx| {
769 window.draw(cx).clear(cx);
770 assert!(!state.read(cx).open);
771 assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "last");
772 });
773 cx.simulate_keystrokes("enter");
774 cx.update(|window, cx| {
775 window.draw(cx).clear(cx);
776 });
777 cx.simulate_click(
778 gpui_kit::point(px(400.), px(400.)),
779 gpui_kit::Modifiers::default(),
780 );
781 cx.update(|window, cx| {
782 window.draw(cx).clear(cx);
783 assert!(!state.read(cx).open);
784 assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "last");
785 });
786 }
787 #[gpui_kit::test]
788 fn keyboard_cursor_scrolls_long_list_into_view(cx: &mut TestAppContext) {
789 let (state, cx) = harness(cx, false, false);
790 cx.update(|window, cx| {
791 state.update(cx, |state, cx| {
792 state.items = (0..30)
793 .map(|i| ChoiceItem::new(i.to_string(), format!("Workspace {i}")))
794 .collect();
795 cx.notify();
796 });
797 window.draw(cx).clear(cx);
798 });
799 cx.simulate_keystrokes("enter");
800 cx.update(|window, cx| {
801 window.draw(cx).clear(cx);
802 });
803 cx.simulate_keystrokes("end");
804 cx.update(|window, cx| {
805 window.draw(cx).clear(cx);
806 });
807 let last = cx.debug_bounds("macos-choice-option-29").unwrap();
808 let popup = cx.debug_bounds("macos-choice-popup").unwrap();
809 assert!(popup.contains(&last.center()));
810 cx.simulate_keystrokes("enter");
811 cx.update(|_, cx| assert_eq!(state.read(cx).selected().unwrap().value.as_ref(), "29"));
812 }
813}