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};
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
32/// Application-owned choice and editing state. Observe this entity for selection changes.
33/// Use one entity per control; Select and Combobox share the same value model.
34pub 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                    // Opening happens before the search input's first render.
158                    // Set its font before edits measure text (system fonts are
159                    // unavailable in the browser).
160                    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
255/// An option picker with j/k and Ctrl-j/k/n/p navigation while open.
256pub 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
278/// A searchable picker with Ctrl-j/k/n/p navigation; plain letters edit the query.
279pub 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}