Skip to main content

gpui_macos/
select.rs

1//! Select and searchable Combobox presentations over the controlled base roots.
2use 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
29/// Application-owned choice and editing state. Observe this entity for selection changes.
30/// Use one entity per control; Select and Combobox share the same value model.
31pub 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                    // Opening happens before the search input's first render.
136                    // Set its font before edits measure text (system fonts are
137                    // unavailable in the browser).
138                    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
230/// An option picker with j/k and Ctrl-j/k/n/p navigation while open.
231pub 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
253/// A searchable picker with Ctrl-j/k/n/p navigation; plain letters edit the query.
254pub 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}