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