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