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
105      .options
106      .iter()
107      .enumerate()
108      .filter(|(_, o)| q.is_empty() || o.to_lowercase().contains(&q))
109      .map(|(i, _)| i)
110      .collect()
111  }
112
113  fn choose(&mut self, index: usize, cx: &mut Context<Self>) {
114    if self.multiple {
115      if let Some(pos) = self.selected.iter().position(|x| *x == index) {
116        self.selected.remove(pos);
117      } else {
118        self.selected.push(index);
119        self.selected.sort_unstable();
120      }
121    } else {
122      self.selected = vec![index];
123      self.open = false;
124      self.query.set_text("");
125    }
126    cx.emit(ComboboxEvent(index));
127    cx.notify();
128  }
129
130  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
131    if self.disabled {
132      return;
133    }
134    let ks = &event.keystroke;
135    // The list owns Escape and Enter; the rest of the trigger is an
136    // ordinary text field over the query.
137    if !ks.modifiers.platform && !ks.modifiers.control {
138      match ks.key.as_str() {
139        "escape" if self.open => {
140          self.open = false;
141          cx.notify();
142          cx.stop_propagation();
143          return;
144        }
145        "enter" => {
146          if let Some(&first) = self.filtered().first() {
147            self.choose(first, cx);
148          }
149          cx.notify();
150          cx.stop_propagation();
151          return;
152        }
153        "down" if !self.open => {
154          self.open = true;
155          cx.notify();
156          cx.stop_propagation();
157          return;
158        }
159        _ => {}
160      }
161    }
162    match line::keys(self, event, window, cx) {
163      KeyOutcome::Edited => {
164        self.line_changed(cx);
165        cx.stop_propagation();
166      }
167      KeyOutcome::Submit | KeyOutcome::Cancel | KeyOutcome::Pass => {}
168    }
169  }
170
171  fn value_text(&self) -> SharedString {
172    match (self.multiple, self.selected.len()) {
173      (_, 0) => self.placeholder.clone(),
174      (true, n) => SharedString::from(format!("{n} selected")),
175      (false, _) => self
176        .selected
177        .first()
178        .and_then(|i| self.options.get(*i))
179        .cloned()
180        .unwrap_or_else(|| self.placeholder.clone()),
181    }
182  }
183}
184
185impl LineEditor for Combobox {
186  fn edit(&self) -> &TextEdit {
187    &self.query
188  }
189
190  fn edit_mut(&mut self) -> &mut TextEdit {
191    &mut self.query
192  }
193
194  fn line(&self) -> &LineState {
195    &self.state
196  }
197
198  fn line_mut(&mut self) -> &mut LineState {
199    &mut self.state
200  }
201
202  fn line_focus(&self) -> &FocusHandle {
203    &self.focus
204  }
205
206  fn line_read_only(&self) -> bool {
207    self.disabled
208  }
209
210  /// Typing into the trigger is what opens the list, so any edit does.
211  fn line_changed(&mut self, cx: &mut Context<Self>) {
212    self.open = true;
213    cx.notify();
214  }
215}
216
217line::line_input_handler!(Combobox);
218line::line_focus_builders!(Combobox);
219
220impl Render for Combobox {
221  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
222    let t = theme(cx);
223    let (height, pad_x, font) = control_metrics(self.size);
224    let radius = t.radius(t.default_radius);
225    let focused = self.focus.is_focused(window) && !self.disabled;
226    let surface = t.surface().hsla();
227    let surface_hover = t.surface_hover().hsla();
228    let border = if focused { t.primary() } else { t.border() }.hsla();
229    let text_color = t.text().hsla();
230    let dimmed = t.dimmed().hsla();
231    let selected_bg = t.primary().alpha(0.12);
232
233    let has_value = !self.selected.is_empty();
234    // With no query typed the field reads as the current selection; start
235    // typing and it becomes the search box. Keeping the same element in
236    // both states is what lets the platform deliver text to it at all.
237    let interior = Line::new(cx.entity()).placeholder(
238      self.value_text(),
239      if has_value { text_color } else { dimmed },
240    );
241
242    let trigger = line::wire(div().id("guise-combobox-trigger"), &self.focus, cx)
243      .on_key_down(cx.listener(Self::on_key))
244      // Layered on top of the shared handlers rather than replacing
245      // them: clicking the field places a caret *and* opens the list.
246      .on_mouse_down(
247        MouseButton::Left,
248        cx.listener(|this, _event, _window, cx| {
249          if !this.disabled {
250            this.open = true;
251            cx.notify();
252          }
253        }),
254      )
255      .flex()
256      .items_center()
257      .justify_between()
258      .gap(px(8.0))
259      .h(px(height))
260      .px(px(pad_x))
261      .rounded(px(radius))
262      .border_1()
263      .border_color(border)
264      .bg(surface)
265      .text_size(px(font))
266      .line_height(px(font * 1.3))
267      .child(div().flex_1().min_w(px(0.0)).child(interior))
268      // Clicking the field places a caret, so the chevron keeps the
269      // open/close toggle the trigger used to be.
270      .child(
271        div()
272          .id("guise-combobox-chevron")
273          .flex_none()
274          .cursor_pointer()
275          .child(
276            Icon::new(IconName::ChevronDown)
277              .size(Size::Xs)
278              .color(crate::theme::ColorName::Gray),
279          )
280          .on_click(cx.listener(|this, _ev, window, cx| {
281            if !this.disabled {
282              this.open = !this.open;
283              window.focus(&this.focus);
284              cx.notify();
285            }
286          })),
287      );
288
289    let mut wrap = div().relative().child(trigger);
290
291    if self.open && !self.disabled {
292      let filtered = self.filtered();
293      let mut menu = div()
294        .absolute()
295        .top(px(height + 6.0))
296        .left(px(0.0))
297        .right(px(0.0))
298        .flex()
299        .flex_col()
300        .gap(px(2.0))
301        .p(px(4.0))
302        .rounded(px(radius))
303        .border_1()
304        .border_color(border)
305        .bg(surface)
306        .shadow_md();
307
308      if filtered.is_empty() {
309        menu = menu.child(
310          div()
311            .px(px(10.0))
312            .py(px(6.0))
313            .text_size(px(font))
314            .text_color(dimmed)
315            .child(SharedString::new_static("No matches")),
316        );
317      }
318      for i in filtered {
319        let is_selected = self.selected.contains(&i);
320        let option = self.options[i].clone();
321        let mut row = div()
322          .id(("guise-combobox-option", i))
323          .flex()
324          .items_center()
325          .justify_between()
326          .px(px(10.0))
327          .py(px(6.0))
328          .rounded(px(4.0))
329          .text_size(px(font))
330          .text_color(text_color)
331          .hover(move |s| s.bg(surface_hover))
332          .child(option)
333          .on_click(cx.listener(move |this, _ev, _window, cx| this.choose(i, cx)));
334        if is_selected {
335          row = row
336            .bg(selected_bg)
337            .child(Icon::new(IconName::Check).size(Size::Xs));
338        }
339        menu = menu.child(row);
340      }
341
342      wrap = wrap.child(deferred(menu));
343    }
344
345    let mut chrome = Field::new().child(if self.disabled {
346      wrap.opacity(0.6)
347    } else {
348      wrap
349    });
350    if let Some(label) = self.label.clone() {
351      chrome = chrome.label(label);
352    }
353    chrome.probe_any("Combobox")
354  }
355}