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