Skip to main content

ui/
combobox.rs

1//! [`Combobox`] — a select you can type into: the closed face of a select over
2//! an anchored menu whose rows narrow as you search.
3//!
4//! An entity for the same reason [`crate::palette::CommandPalette`] is one — it
5//! owns a query [`input::TextField`]. The two share [`popover::Filter`] and differ only
6//! in frame: the palette is a modal over every command, this hangs under a
7//! trigger and remembers what was chosen.
8//!
9//! ```ignore
10//! ui::combobox::init(cx);   // once, at startup (with input::init)
11//! let language = cx.new(|cx| Combobox::new(vec!["Rust".into()], "Language", cx));
12//! cx.subscribe(&language, |_, _, event, _| match event {
13//!     ComboboxEvent::Selected(index) => { /* item `index` */ }
14//! })
15//! .detach();
16//! ```
17
18use crate::{input, popover, search::SearchList, widgets::Controls};
19use gpui::{
20    App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, Pixels, SharedString, Window,
21    actions, canvas, div, prelude::*, px,
22};
23use theme::Theme;
24
25actions!(
26    bezel_combobox,
27    [SelectNext, SelectPrevious, Confirm, Dismiss]
28);
29
30/// The key context the combobox claims. It wraps the query field's own
31/// context, so typing goes to the field while navigation keys fall through.
32pub const KEY_CONTEXT: &str = "Combobox";
33
34/// Install the bindings — [`bindings`], bound. Call once, alongside
35/// [`crate::input::init`].
36pub fn init(cx: &mut App) {
37    cx.bind_keys(bindings());
38}
39
40/// The combobox's navigation keymap, as data, so an app can have it without having to
41/// take it — see [`crate::keys`] for layering over it or taking a chord
42/// away.
43pub fn bindings() -> Vec<KeyBinding> {
44    let mut bindings = Vec::new();
45    let ctx = Some(KEY_CONTEXT);
46    bindings.extend([
47        KeyBinding::new("down", SelectNext, ctx),
48        KeyBinding::new("up", SelectPrevious, ctx),
49        KeyBinding::new("enter", Confirm, ctx),
50        KeyBinding::new("escape", Dismiss, ctx),
51        KeyBinding::new("ctrl-n", SelectNext, ctx),
52        KeyBinding::new("ctrl-p", SelectPrevious, ctx),
53    ]);
54
55    bindings
56}
57
58/// What the combobox reports. The index is into the ORIGINAL item list, never
59/// into the filtered view.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum ComboboxEvent {
62    Selected(usize),
63}
64
65pub struct Combobox {
66    search: SearchList,
67    menu: popover::Popup<()>,
68    chosen: Option<usize>,
69    placeholder: SharedString,
70    /// The trigger's laid-out width, measured last frame — the menu matches
71    /// it. An anchored layer sizes to its own content, so without measuring,
72    /// a combobox's menu could not line up with its face.
73    trigger_width: Option<Pixels>,
74    focus_handle: FocusHandle,
75}
76
77impl EventEmitter<ComboboxEvent> for Combobox {}
78
79impl Combobox {
80    pub fn new(
81        items: Vec<SharedString>,
82        placeholder: impl Into<SharedString>,
83        cx: &mut Context<Self>,
84    ) -> Self {
85        Self {
86            search: SearchList::new(items, "Search…", |view: &mut Self| &mut view.search, cx),
87            menu: popover::Popup::default(),
88            chosen: None,
89            placeholder: placeholder.into(),
90            trigger_width: None,
91            // One stop per combobox: the query field is inside `menu_card`, so
92            // it only joins the order while the menu is actually open.
93            focus_handle: cx.focus_handle().tab_stop(true),
94        }
95    }
96
97    /// Preselect an item — the value a form field starts with.
98    pub fn with_selection(mut self, item: usize) -> Self {
99        self.chosen = (item < self.search.filter.items().len()).then_some(item);
100        self
101    }
102
103    pub fn selection(&self) -> Option<usize> {
104        self.chosen
105    }
106
107    fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
108        self.search.clear(cx);
109        if let Some(chosen) = self.chosen {
110            self.search.filter.set_active(chosen);
111        }
112        self.menu.open(());
113        window.focus(&self.search.query.focus_handle(cx), cx);
114        cx.notify();
115    }
116
117    fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
118        if self.menu.take_press_was_open() {
119            self.close(window, cx);
120        } else {
121            self.open(window, cx);
122        }
123    }
124
125    fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
126        // Restore only focus owned by the query: an outside click may already
127        // have focused another control.
128        if self.search.query.focus_handle(cx).is_focused(window) {
129            window.focus(&self.focus_handle, cx);
130        }
131        popover::close_popup(self, cx, |view| &mut view.menu);
132    }
133
134    fn choose(&mut self, item: usize, window: &mut Window, cx: &mut Context<Self>) {
135        if !self.menu.is_open() {
136            return;
137        }
138        self.chosen = Some(item);
139        cx.emit(ComboboxEvent::Selected(item));
140        self.close(window, cx);
141    }
142
143    fn step(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
144        if self.menu.is_open() {
145            self.search.filter.step(delta);
146            cx.notify();
147        } else if !self.menu.is_closing() {
148            self.open(window, cx);
149        }
150    }
151
152    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
153        self.step(1, window, cx);
154    }
155
156    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
157        self.step(-1, window, cx);
158    }
159
160    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
161        if self.menu.is_open() {
162            if let Some(item) = self.search.filter.active_item() {
163                self.choose(item, window, cx);
164            }
165        } else if !self.menu.is_closing() {
166            self.open(window, cx);
167        }
168    }
169
170    fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
171        if self.menu.is_open() {
172            self.close(window, cx);
173        } else {
174            cx.propagate();
175        }
176    }
177
178    fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
179        popover::popover_card(theme)
180            .w(self.trigger_width.unwrap_or(px(200.0)))
181            .on_mouse_down_out(cx.listener(|view, _, window, cx| view.close(window, cx)))
182            .child(self.search.body(
183                theme,
184                self.chosen,
185                |view| &mut view.search,
186                Self::choose,
187                cx,
188            ))
189            .into_any_element()
190    }
191}
192
193impl Focusable for Combobox {
194    /// The query field holds focus while open; this is the context around it.
195    fn focus_handle(&self, _: &App) -> FocusHandle {
196        self.focus_handle.clone()
197    }
198}
199
200impl Render for Combobox {
201    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
202        let theme = Theme::of(cx).clone();
203        let open = self.menu.is_open() || self.menu.is_closing();
204        let label = match self.chosen {
205            Some(item) => self.search.filter.items()[item].clone(),
206            None => self.placeholder.clone(),
207        };
208        let card = open.then(|| self.menu_card(&theme, cx));
209        let combobox = cx.entity().downgrade();
210
211        div()
212            .key_context(KEY_CONTEXT)
213            .track_focus(&self.focus_handle)
214            .on_action(cx.listener(Self::select_next))
215            .on_action(cx.listener(Self::select_previous))
216            .on_action(cx.listener(Self::confirm))
217            .on_action(cx.listener(Self::dismiss))
218            .relative()
219            .w_full()
220            // Records the trigger width for next frame's menu; the trigger is
221            // always on screen before the menu opens, so it is never unset
222            // when it matters.
223            .child(
224                canvas(
225                    move |bounds, _, cx| {
226                        combobox
227                            .update(cx, |combobox, _| {
228                                combobox.trigger_width = Some(bounds.size.width);
229                            })
230                            .ok();
231                    },
232                    |_, _, _, _| {},
233                )
234                .absolute()
235                .size_full(),
236            )
237            .child(popover::trigger_press(
238                div()
239                    .id("combobox-trigger")
240                    .on_click(cx.listener(|combobox, _, window, cx| combobox.toggle(window, cx)))
241                    .child(theme.select_trigger(label)),
242                |combobox: &mut Self| &mut combobox.menu,
243                cx,
244            ))
245            .when_some(card, |trigger, card| {
246                trigger.child(popover::anchored_menu_below(
247                    "combobox-menu",
248                    card,
249                    self.menu.closing_since(),
250                ))
251            })
252    }
253}
254
255/// Re-exported so a host can wire the field's context without depending on
256/// [`crate::input`] directly.
257pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;