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, SharedString,
11    Window,
12};
13
14use super::{control_metrics, Field, TextEdit};
15use crate::icon::{Icon, IconName};
16use crate::theme::{theme, Size};
17
18/// Emitted when an option is chosen/toggled. Carries the option index.
19#[derive(Debug, Clone, Copy)]
20pub struct ComboboxEvent(pub usize);
21
22/// A searchable picker. Create with `cx.new(|cx| Combobox::new(cx).data([..]))`.
23pub struct Combobox {
24    options: Vec<SharedString>,
25    selected: Vec<usize>,
26    query: TextEdit,
27    open: bool,
28    multiple: bool,
29    focus: FocusHandle,
30    placeholder: SharedString,
31    label: Option<SharedString>,
32    size: Size,
33    disabled: bool,
34}
35
36impl EventEmitter<ComboboxEvent> for Combobox {}
37
38impl Combobox {
39    pub fn new(cx: &mut Context<Self>) -> Self {
40        Combobox {
41            options: Vec::new(),
42            selected: Vec::new(),
43            query: TextEdit::new(""),
44            open: false,
45            multiple: false,
46            focus: cx.focus_handle(),
47            placeholder: SharedString::new_static("Search…"),
48            label: None,
49            size: Size::Sm,
50            disabled: false,
51        }
52    }
53
54    pub fn data<I, S>(mut self, options: I) -> Self
55    where
56        I: IntoIterator<Item = S>,
57        S: Into<SharedString>,
58    {
59        self.options = options.into_iter().map(Into::into).collect();
60        self
61    }
62
63    pub fn multiple(mut self, multiple: bool) -> Self {
64        self.multiple = multiple;
65        self
66    }
67
68    pub fn selected(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
69        self.selected = indices.into_iter().collect();
70        self
71    }
72
73    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
74        self.placeholder = placeholder.into();
75        self
76    }
77
78    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
79        self.label = Some(label.into());
80        self
81    }
82
83    pub fn size(mut self, size: Size) -> Self {
84        self.size = size;
85        self
86    }
87
88    pub fn disabled(mut self, disabled: bool) -> Self {
89        self.disabled = disabled;
90        self
91    }
92
93    pub fn selected_indices(&self) -> &[usize] {
94        &self.selected
95    }
96
97    /// Indices of options matching the current query.
98    fn filtered(&self) -> Vec<usize> {
99        let q = self.query.text().to_lowercase();
100        self.options
101            .iter()
102            .enumerate()
103            .filter(|(_, o)| q.is_empty() || o.to_lowercase().contains(&q))
104            .map(|(i, _)| i)
105            .collect()
106    }
107
108    fn choose(&mut self, index: usize, cx: &mut Context<Self>) {
109        if self.multiple {
110            if let Some(pos) = self.selected.iter().position(|x| *x == index) {
111                self.selected.remove(pos);
112            } else {
113                self.selected.push(index);
114                self.selected.sort_unstable();
115            }
116        } else {
117            self.selected = vec![index];
118            self.open = false;
119            self.query = TextEdit::new("");
120        }
121        cx.emit(ComboboxEvent(index));
122        cx.notify();
123    }
124
125    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
126        if self.disabled {
127            return;
128        }
129        let ks = &event.keystroke;
130        if ks.modifiers.platform || ks.modifiers.control {
131            return;
132        }
133        match ks.key.as_str() {
134            "escape" => {
135                self.open = false;
136            }
137            "enter" => {
138                if let Some(&first) = self.filtered().first() {
139                    self.choose(first, cx);
140                }
141            }
142            "backspace" => {
143                self.query.backspace();
144                self.open = true;
145            }
146            "left" => self.query.left(),
147            "right" => self.query.right(),
148            _ => {
149                if let Some(text) = ks
150                    .key_char
151                    .as_deref()
152                    .filter(|t| !t.is_empty() && !ks.modifiers.alt)
153                {
154                    self.query.insert(text);
155                    self.open = true;
156                }
157            }
158        }
159        cx.notify();
160        cx.stop_propagation();
161    }
162
163    fn value_text(&self) -> SharedString {
164        match (self.multiple, self.selected.len()) {
165            (_, 0) => self.placeholder.clone(),
166            (true, n) => SharedString::from(format!("{n} selected")),
167            (false, _) => self
168                .selected
169                .first()
170                .and_then(|i| self.options.get(*i))
171                .cloned()
172                .unwrap_or_else(|| self.placeholder.clone()),
173        }
174    }
175}
176
177impl Render for Combobox {
178    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
179        let t = theme(cx);
180        let (height, pad_x, font) = control_metrics(self.size);
181        let radius = t.radius(t.default_radius);
182        let focused = self.focus.is_focused(window) && !self.disabled;
183        let surface = t.surface().hsla();
184        let surface_hover = t.surface_hover().hsla();
185        let border = if focused { t.primary() } else { t.border() }.hsla();
186        let text_color = t.text().hsla();
187        let dimmed = t.dimmed().hsla();
188        let caret = t.primary().hsla();
189        let selected_bg = t.primary().alpha(0.12);
190
191        let has_value = !self.selected.is_empty();
192        let interior = if self.open && focused {
193            let (before, after) = self.query.split();
194            div()
195                .flex()
196                .items_center()
197                .text_color(text_color)
198                .child(SharedString::from(before))
199                .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
200                .child(SharedString::from(after))
201        } else {
202            div()
203                .text_color(if has_value { text_color } else { dimmed })
204                .child(self.value_text())
205        };
206
207        let trigger = div()
208            .id("guise-combobox-trigger")
209            .track_focus(&self.focus)
210            .on_key_down(cx.listener(Self::on_key))
211            .on_click(cx.listener(|this, _ev, window, cx| {
212                if !this.disabled {
213                    this.open = !this.open;
214                    window.focus(&this.focus);
215                    cx.notify();
216                }
217            }))
218            .flex()
219            .items_center()
220            .justify_between()
221            .gap(px(8.0))
222            .h(px(height))
223            .px(px(pad_x))
224            .rounded(px(radius))
225            .border_1()
226            .border_color(border)
227            .bg(surface)
228            .text_size(px(font))
229            .child(interior)
230            .child(
231                Icon::new(IconName::ChevronDown)
232                    .size(Size::Xs)
233                    .color(crate::theme::ColorName::Gray),
234            );
235
236        let mut wrap = div().relative().child(trigger);
237
238        if self.open && !self.disabled {
239            let filtered = self.filtered();
240            let mut menu = div()
241                .absolute()
242                .top(px(height + 6.0))
243                .left(px(0.0))
244                .right(px(0.0))
245                .flex()
246                .flex_col()
247                .gap(px(2.0))
248                .p(px(4.0))
249                .rounded(px(radius))
250                .border_1()
251                .border_color(border)
252                .bg(surface)
253                .shadow_md();
254
255            if filtered.is_empty() {
256                menu = menu.child(
257                    div()
258                        .px(px(10.0))
259                        .py(px(6.0))
260                        .text_size(px(font))
261                        .text_color(dimmed)
262                        .child(SharedString::new_static("No matches")),
263                );
264            }
265            for i in filtered {
266                let is_selected = self.selected.contains(&i);
267                let option = self.options[i].clone();
268                let mut row = div()
269                    .id(("guise-combobox-option", i))
270                    .flex()
271                    .items_center()
272                    .justify_between()
273                    .px(px(10.0))
274                    .py(px(6.0))
275                    .rounded(px(4.0))
276                    .text_size(px(font))
277                    .text_color(text_color)
278                    .hover(move |s| s.bg(surface_hover))
279                    .child(option)
280                    .on_click(cx.listener(move |this, _ev, _window, cx| this.choose(i, cx)));
281                if is_selected {
282                    row = row
283                        .bg(selected_bg)
284                        .child(Icon::new(IconName::Check).size(Size::Xs));
285                }
286                menu = menu.child(row);
287            }
288
289            wrap = wrap.child(deferred(menu));
290        }
291
292        let mut chrome = Field::new().child(if self.disabled {
293            wrap.opacity(0.6)
294        } else {
295            wrap
296        });
297        if let Some(label) = self.label.clone() {
298            chrome = chrome.label(label);
299        }
300        chrome
301    }
302}