Skip to main content

guise/input/
combobox.rs

1//! `Combobox` — a searchable [`Select`](super::Select) (gpui entity).
2//!
3//! The trigger is an editable query field; the deferred list filters by a
4//! case-insensitive substring match. Single-select closes on choice; with
5//! [`Combobox::multiple`] it keeps a selection set and stays open. Emits
6//! [`ComboboxEvent`] with the toggled option index.
7
8use gpui::prelude::*;
9use gpui::{
10    deferred, div, px, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
11    SharedString, Window,
12};
13
14use super::line::{self, Line, LineEditor, LineState};
15use super::{control_metrics, Field, KeyOutcome, TextEdit};
16use crate::devtools::ProbedAny;
17use crate::icon::{Icon, IconName};
18use crate::theme::{theme, Size};
19
20/// Emitted when an option is chosen/toggled. Carries the option index.
21#[derive(Debug, Clone, Copy)]
22pub struct ComboboxEvent(pub usize);
23
24/// A searchable picker. Create with `cx.new(|cx| Combobox::new(cx).data([..]))`.
25pub struct Combobox {
26    options: Vec<SharedString>,
27    selected: Vec<usize>,
28    query: TextEdit,
29    state: LineState,
30    open: bool,
31    multiple: bool,
32    focus: FocusHandle,
33    placeholder: SharedString,
34    label: Option<SharedString>,
35    size: Size,
36    disabled: bool,
37}
38
39impl EventEmitter<ComboboxEvent> for Combobox {}
40
41impl Combobox {
42    pub fn new(cx: &mut Context<Self>) -> Self {
43        Combobox {
44            options: Vec::new(),
45            selected: Vec::new(),
46            query: TextEdit::new(""),
47            state: LineState::new(),
48            open: false,
49            multiple: false,
50            focus: cx.focus_handle().tab_stop(true),
51            placeholder: SharedString::new_static("Search…"),
52            label: None,
53            size: Size::Sm,
54            disabled: false,
55        }
56    }
57
58    pub fn data<I, S>(mut self, options: I) -> Self
59    where
60        I: IntoIterator<Item = S>,
61        S: Into<SharedString>,
62    {
63        self.options = options.into_iter().map(Into::into).collect();
64        self
65    }
66
67    pub fn multiple(mut self, multiple: bool) -> Self {
68        self.multiple = multiple;
69        self
70    }
71
72    pub fn selected(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
73        self.selected = indices.into_iter().collect();
74        self
75    }
76
77    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
78        self.placeholder = placeholder.into();
79        self
80    }
81
82    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
83        self.label = Some(label.into());
84        self
85    }
86
87    pub fn size(mut self, size: Size) -> Self {
88        self.size = size;
89        self
90    }
91
92    pub fn disabled(mut self, disabled: bool) -> Self {
93        self.disabled = disabled;
94        self
95    }
96
97    pub fn selected_indices(&self) -> &[usize] {
98        &self.selected
99    }
100
101    /// Indices of options matching the current query.
102    fn filtered(&self) -> Vec<usize> {
103        let q = self.query.text().to_lowercase();
104        self.options
105            .iter()
106            .enumerate()
107            .filter(|(_, o)| q.is_empty() || o.to_lowercase().contains(&q))
108            .map(|(i, _)| i)
109            .collect()
110    }
111
112    fn choose(&mut self, index: usize, cx: &mut Context<Self>) {
113        if self.multiple {
114            if let Some(pos) = self.selected.iter().position(|x| *x == index) {
115                self.selected.remove(pos);
116            } else {
117                self.selected.push(index);
118                self.selected.sort_unstable();
119            }
120        } else {
121            self.selected = vec![index];
122            self.open = false;
123            self.query.set_text("");
124        }
125        cx.emit(ComboboxEvent(index));
126        cx.notify();
127    }
128
129    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
130        if self.disabled {
131            return;
132        }
133        let ks = &event.keystroke;
134        // The list owns Escape and Enter; the rest of the trigger is an
135        // ordinary text field over the query.
136        if !ks.modifiers.platform && !ks.modifiers.control {
137            match ks.key.as_str() {
138                "escape" if self.open => {
139                    self.open = false;
140                    cx.notify();
141                    cx.stop_propagation();
142                    return;
143                }
144                "enter" => {
145                    if let Some(&first) = self.filtered().first() {
146                        self.choose(first, cx);
147                    }
148                    cx.notify();
149                    cx.stop_propagation();
150                    return;
151                }
152                "down" if !self.open => {
153                    self.open = true;
154                    cx.notify();
155                    cx.stop_propagation();
156                    return;
157                }
158                _ => {}
159            }
160        }
161        match line::keys(self, event, window, cx) {
162            KeyOutcome::Edited => {
163                self.line_changed(cx);
164                cx.stop_propagation();
165            }
166            KeyOutcome::Submit | KeyOutcome::Cancel | KeyOutcome::Pass => {}
167        }
168    }
169
170    fn value_text(&self) -> SharedString {
171        match (self.multiple, self.selected.len()) {
172            (_, 0) => self.placeholder.clone(),
173            (true, n) => SharedString::from(format!("{n} selected")),
174            (false, _) => self
175                .selected
176                .first()
177                .and_then(|i| self.options.get(*i))
178                .cloned()
179                .unwrap_or_else(|| self.placeholder.clone()),
180        }
181    }
182}
183
184impl LineEditor for Combobox {
185    fn edit(&self) -> &TextEdit {
186        &self.query
187    }
188
189    fn edit_mut(&mut self) -> &mut TextEdit {
190        &mut self.query
191    }
192
193    fn line(&self) -> &LineState {
194        &self.state
195    }
196
197    fn line_mut(&mut self) -> &mut LineState {
198        &mut self.state
199    }
200
201    fn line_focus(&self) -> &FocusHandle {
202        &self.focus
203    }
204
205    fn line_read_only(&self) -> bool {
206        self.disabled
207    }
208
209    /// Typing into the trigger is what opens the list, so any edit does.
210    fn line_changed(&mut self, cx: &mut Context<Self>) {
211        self.open = true;
212        cx.notify();
213    }
214}
215
216line::line_input_handler!(Combobox);
217line::line_focus_builders!(Combobox);
218
219impl Render for Combobox {
220    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
221        let t = theme(cx);
222        let (height, pad_x, font) = control_metrics(self.size);
223        let radius = t.radius(t.default_radius);
224        let focused = self.focus.is_focused(window) && !self.disabled;
225        let surface = t.surface().hsla();
226        let surface_hover = t.surface_hover().hsla();
227        let border = if focused { t.primary() } else { t.border() }.hsla();
228        let text_color = t.text().hsla();
229        let dimmed = t.dimmed().hsla();
230        let selected_bg = t.primary().alpha(0.12);
231
232        let has_value = !self.selected.is_empty();
233        // With no query typed the field reads as the current selection; start
234        // typing and it becomes the search box. Keeping the same element in
235        // both states is what lets the platform deliver text to it at all.
236        let interior = Line::new(cx.entity()).placeholder(
237            self.value_text(),
238            if has_value { text_color } else { dimmed },
239        );
240
241        let trigger = line::wire(div().id("guise-combobox-trigger"), &self.focus, cx)
242            .on_key_down(cx.listener(Self::on_key))
243            // Layered on top of the shared handlers rather than replacing
244            // them: clicking the field places a caret *and* opens the list.
245            .on_mouse_down(
246                MouseButton::Left,
247                cx.listener(|this, _event, _window, cx| {
248                    if !this.disabled {
249                        this.open = true;
250                        cx.notify();
251                    }
252                }),
253            )
254            .flex()
255            .items_center()
256            .justify_between()
257            .gap(px(8.0))
258            .h(px(height))
259            .px(px(pad_x))
260            .rounded(px(radius))
261            .border_1()
262            .border_color(border)
263            .bg(surface)
264            .text_size(px(font))
265            .line_height(px(font * 1.3))
266            .child(div().flex_1().min_w(px(0.0)).child(interior))
267            // Clicking the field places a caret, so the chevron keeps the
268            // open/close toggle the trigger used to be.
269            .child(
270                div()
271                    .id("guise-combobox-chevron")
272                    .flex_none()
273                    .cursor_pointer()
274                    .child(
275                        Icon::new(IconName::ChevronDown)
276                            .size(Size::Xs)
277                            .color(crate::theme::ColorName::Gray),
278                    )
279                    .on_click(cx.listener(|this, _ev, window, cx| {
280                        if !this.disabled {
281                            this.open = !this.open;
282                            window.focus(&this.focus);
283                            cx.notify();
284                        }
285                    })),
286            );
287
288        let mut wrap = div().relative().child(trigger);
289
290        if self.open && !self.disabled {
291            let filtered = self.filtered();
292            let mut menu = div()
293                .absolute()
294                .top(px(height + 6.0))
295                .left(px(0.0))
296                .right(px(0.0))
297                .flex()
298                .flex_col()
299                .gap(px(2.0))
300                .p(px(4.0))
301                .rounded(px(radius))
302                .border_1()
303                .border_color(border)
304                .bg(surface)
305                .shadow_md();
306
307            if filtered.is_empty() {
308                menu = menu.child(
309                    div()
310                        .px(px(10.0))
311                        .py(px(6.0))
312                        .text_size(px(font))
313                        .text_color(dimmed)
314                        .child(SharedString::new_static("No matches")),
315                );
316            }
317            for i in filtered {
318                let is_selected = self.selected.contains(&i);
319                let option = self.options[i].clone();
320                let mut row = div()
321                    .id(("guise-combobox-option", i))
322                    .flex()
323                    .items_center()
324                    .justify_between()
325                    .px(px(10.0))
326                    .py(px(6.0))
327                    .rounded(px(4.0))
328                    .text_size(px(font))
329                    .text_color(text_color)
330                    .hover(move |s| s.bg(surface_hover))
331                    .child(option)
332                    .on_click(cx.listener(move |this, _ev, _window, cx| this.choose(i, cx)));
333                if is_selected {
334                    row = row
335                        .bg(selected_bg)
336                        .child(Icon::new(IconName::Check).size(Size::Xs));
337                }
338                menu = menu.child(row);
339            }
340
341            wrap = wrap.child(deferred(menu));
342        }
343
344        let mut chrome = Field::new().child(if self.disabled {
345            wrap.opacity(0.6)
346        } else {
347            wrap
348        });
349        if let Some(label) = self.label.clone() {
350            chrome = chrome.label(label);
351        }
352        chrome.probe_any("Combobox")
353    }
354}