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 [`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::{self, TextField},
20    popover,
21    widgets::Controls,
22};
23use gpui::{
24    App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Pixels, SharedString,
25    Window, actions, canvas, div, prelude::*, px,
26};
27use theme::Theme;
28
29actions!(
30    bezel_combobox,
31    [SelectNext, SelectPrevious, Confirm, Dismiss]
32);
33
34/// The key context the combobox claims. It wraps the query field's own
35/// context, so typing goes to the field while navigation keys fall through.
36pub const KEY_CONTEXT: &str = "Combobox";
37
38/// Install the combobox's navigation bindings. Call once, alongside
39/// [`crate::input::init`].
40pub fn init(cx: &mut App) {
41    let ctx = Some(KEY_CONTEXT);
42    cx.bind_keys([
43        KeyBinding::new("down", SelectNext, ctx),
44        KeyBinding::new("up", SelectPrevious, ctx),
45        KeyBinding::new("enter", Confirm, ctx),
46        KeyBinding::new("escape", Dismiss, ctx),
47        KeyBinding::new("ctrl-n", SelectNext, ctx),
48        KeyBinding::new("ctrl-p", SelectPrevious, ctx),
49    ]);
50}
51
52/// What the combobox reports. The index is into the ORIGINAL item list, never
53/// into the filtered view.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum ComboboxEvent {
56    Selected(usize),
57}
58
59pub struct Combobox {
60    query: Entity<TextField>,
61    filter: popover::Filter,
62    menu: popover::Popup<()>,
63    chosen: Option<usize>,
64    placeholder: SharedString,
65    /// The trigger's laid-out width, measured last frame — the menu matches
66    /// it. An anchored layer sizes to its own content, so without measuring,
67    /// a combobox's menu could not line up with its face.
68    trigger_width: Option<Pixels>,
69    focus_handle: FocusHandle,
70}
71
72impl EventEmitter<ComboboxEvent> for Combobox {}
73
74impl Combobox {
75    pub fn new(
76        items: Vec<SharedString>,
77        placeholder: impl Into<SharedString>,
78        cx: &mut Context<Self>,
79    ) -> Self {
80        let query = cx.new(|cx| TextField::new(cx).with_placeholder("Search…"));
81        cx.observe(&query, |combobox, _, cx| {
82            let query = combobox.query.read(cx).content().clone();
83            combobox.filter.refilter(&query);
84            cx.notify();
85        })
86        .detach();
87        Self {
88            query,
89            filter: popover::Filter::new(items),
90            menu: popover::Popup::default(),
91            chosen: None,
92            placeholder: placeholder.into(),
93            trigger_width: None,
94            // One stop per combobox: the query field is inside `menu_card`, so
95            // it only joins the order while the menu is actually open.
96            focus_handle: cx.focus_handle().tab_stop(true),
97        }
98    }
99
100    /// Preselect an item — the value a form field starts with.
101    pub fn with_selection(mut self, item: usize) -> Self {
102        self.chosen = (item < self.filter.items().len()).then_some(item);
103        self
104    }
105
106    pub fn selection(&self) -> Option<usize> {
107        self.chosen
108    }
109
110    fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
111        // The press note was taken on mouse-down: if the menu was mounted
112        // then, this click is the dismissal, not a fresh open.
113        if self.menu.take_press_was_open() {
114            self.close(cx);
115        } else {
116            // A stale query would reopen the menu already narrowed.
117            self.query.update(cx, |query, cx| query.clear(cx));
118            self.filter.refilter("");
119            self.menu.open(());
120            window.focus(&self.query.focus_handle(cx), cx);
121            cx.notify();
122        }
123    }
124
125    fn close(&mut self, cx: &mut Context<Self>) {
126        if self.menu.begin_close() {
127            popover::reap_popup(cx, |combobox: &mut Self| &mut combobox.menu);
128        }
129        cx.notify();
130    }
131
132    fn choose(&mut self, item: usize, cx: &mut Context<Self>) {
133        self.chosen = Some(item);
134        cx.emit(ComboboxEvent::Selected(item));
135        self.close(cx);
136    }
137
138    fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
139        self.filter.step(1);
140        cx.notify();
141    }
142
143    fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
144        self.filter.step(-1);
145        cx.notify();
146    }
147
148    fn confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
149        if let Some(item) = self.filter.active_item() {
150            self.choose(item, cx);
151        }
152    }
153
154    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
155        self.close(cx);
156    }
157
158    fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
159        // Element ids are namespaced by the view; hover-fade keys are a global
160        // map, so those carry the entity id — a form can hold several of these.
161        let view = cx.entity_id();
162        let rows: Vec<gpui::AnyElement> = self
163            .filter
164            .filtered()
165            .iter()
166            .enumerate()
167            .map(|(position, &item)| {
168                popover::menu_row_nav(
169                    theme,
170                    Some(item) == self.chosen,
171                    Some(position) == self.filter.active(),
172                    SharedString::from(format!("combobox-{view}-row-{item}")),
173                )
174                .id(SharedString::from(format!("row-{item}")))
175                .on_click(cx.listener(move |combobox, _, _, cx| combobox.choose(item, cx)))
176                .child(self.filter.items()[item].clone())
177                .into_any_element()
178            })
179            .collect();
180
181        popover::popover_card(theme)
182            .w(self.trigger_width.unwrap_or(px(200.0)))
183            .on_mouse_down_out(cx.listener(|combobox, _, _, cx| combobox.close(cx)))
184            .child(popover::search_input_frame(
185                theme,
186                self.query.clone().into_any_element(),
187            ))
188            .child(if rows.is_empty() {
189                div()
190                    .px(px(10.0))
191                    .py(px(8.0))
192                    .text_size(px(13.0))
193                    .text_color(theme.text_muted)
194                    .child("No matches")
195                    .into_any_element()
196            } else {
197                div().flex().flex_col().children(rows).into_any_element()
198            })
199            .into_any_element()
200    }
201}
202
203impl Focusable for Combobox {
204    /// The query field holds focus while open; this is the context around it.
205    fn focus_handle(&self, _: &App) -> FocusHandle {
206        self.focus_handle.clone()
207    }
208}
209
210impl Render for Combobox {
211    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
212        let theme = Theme::of(cx).clone();
213        let open = self.menu.is_open() || self.menu.is_closing();
214        let label = match self.chosen {
215            Some(item) => self.filter.items()[item].clone(),
216            None => self.placeholder.clone(),
217        };
218        let card = open.then(|| self.menu_card(&theme, cx));
219        let combobox = cx.entity().downgrade();
220
221        div()
222            .key_context(KEY_CONTEXT)
223            .track_focus(&self.focus_handle)
224            .on_action(cx.listener(Self::select_next))
225            .on_action(cx.listener(Self::select_previous))
226            .on_action(cx.listener(Self::confirm))
227            .on_action(cx.listener(Self::dismiss))
228            .relative()
229            .w_full()
230            // Records the trigger width for next frame's menu; the trigger is
231            // always on screen before the menu opens, so it is never unset
232            // when it matters.
233            .child(
234                canvas(
235                    move |bounds, _, cx| {
236                        combobox
237                            .update(cx, |combobox, _| {
238                                combobox.trigger_width = Some(bounds.size.width);
239                            })
240                            .ok();
241                    },
242                    |_, _, _, _| {},
243                )
244                .absolute()
245                .size_full(),
246            )
247            .child(
248                div()
249                    .id("combobox-trigger")
250                    .on_mouse_down(
251                        gpui::MouseButton::Left,
252                        cx.listener(|combobox, _, _, _| combobox.menu.note_trigger_press()),
253                    )
254                    .on_click(cx.listener(|combobox, _, window, cx| combobox.toggle(window, cx)))
255                    .child(theme.select_trigger(label, open)),
256            )
257            .when_some(card, |trigger, card| {
258                trigger.child(popover::anchored_menu_below(
259                    "combobox-menu",
260                    card,
261                    self.menu.closing_since(),
262                ))
263            })
264    }
265}
266
267/// Re-exported so a host can wire the field's context without depending on
268/// [`crate::input`] directly.
269pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;