Skip to main content

guise/input/
autocomplete.rs

1//! `Autocomplete` — a freeform text field with suggestions (gpui entity).
2//!
3//! Unlike [`Combobox`](super::Combobox) (pick one of the options), the value
4//! here is whatever the user types — suggestions are shortcuts, not
5//! constraints. Arrow keys walk the list, Enter adopts the highlighted
6//! suggestion (or commits the typed text), Escape closes.
7
8use gpui::prelude::*;
9use gpui::{
10  deferred, div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
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::reactive::Signal;
18use crate::theme::{theme, Size};
19
20/// Emitted as the value changes and when it's committed.
21#[derive(Debug, Clone)]
22pub enum AutocompleteEvent {
23  /// Every edit; carries the current text.
24  Change(String),
25  /// Enter or a suggestion click; carries the final text.
26  Commit(String),
27}
28
29/// Indices of suggestions matching `query` (case-insensitive substring).
30/// An empty query matches nothing — the list is a typing aid, not a menu.
31fn matches(suggestions: &[SharedString], query: &str) -> Vec<usize> {
32  if query.is_empty() {
33    return Vec::new();
34  }
35  let q = query.to_lowercase();
36  suggestions
37    .iter()
38    .enumerate()
39    .filter(|(_, s)| s.to_lowercase().contains(&q))
40    .map(|(i, _)| i)
41    .collect()
42}
43
44/// A text field with completion. Create with
45/// `cx.new(|cx| Autocomplete::new(cx).suggestions([..]))`.
46pub struct Autocomplete {
47  suggestions: Vec<SharedString>,
48  edit: TextEdit,
49  state: LineState,
50  open: bool,
51  highlight: usize,
52  max_shown: usize,
53  focus: FocusHandle,
54  placeholder: SharedString,
55  label: Option<SharedString>,
56  size: Size,
57  disabled: bool,
58}
59
60impl EventEmitter<AutocompleteEvent> for Autocomplete {}
61
62impl Autocomplete {
63  pub fn new(cx: &mut Context<Self>) -> Self {
64    Autocomplete {
65      suggestions: Vec::new(),
66      edit: TextEdit::new(""),
67      state: LineState::new(),
68      open: false,
69      highlight: 0,
70      max_shown: 8,
71      focus: cx.focus_handle().tab_stop(true),
72      placeholder: SharedString::new_static("Type…"),
73      label: None,
74      size: Size::Sm,
75      disabled: false,
76    }
77  }
78
79  pub fn suggestions<I, S>(mut self, suggestions: I) -> Self
80  where
81    I: IntoIterator<Item = S>,
82    S: Into<SharedString>,
83  {
84    self.suggestions = suggestions.into_iter().map(Into::into).collect();
85    self
86  }
87
88  pub fn value(mut self, value: impl Into<String>) -> Self {
89    self.edit = TextEdit::new(&value.into());
90    self
91  }
92
93  /// Cap the dropdown length (default 8).
94  pub fn max_shown(mut self, max: usize) -> Self {
95    self.max_shown = max.max(1);
96    self
97  }
98
99  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
100    self.placeholder = placeholder.into();
101    self
102  }
103
104  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
105    self.label = Some(label.into());
106    self
107  }
108
109  pub fn size(mut self, size: Size) -> Self {
110    self.size = size;
111    self
112  }
113
114  pub fn disabled(mut self, disabled: bool) -> Self {
115    self.disabled = disabled;
116    self
117  }
118
119  pub fn text(&self) -> String {
120    self.edit.text().to_string()
121  }
122
123  /// Two-way bind the text to a `Signal<String>`. The signal is the source
124  /// of truth; equality guards on both directions prevent loops.
125  pub fn bind(entity: &Entity<Autocomplete>, signal: &Signal<String>, cx: &mut App) {
126    let initial = signal.get(cx);
127    entity.update(cx, |this, cx| this.sync_text(initial, cx));
128    let sink = signal.clone();
129    cx.subscribe(entity, move |_this, event: &AutocompleteEvent, cx| {
130      if let AutocompleteEvent::Change(text) = event {
131        sink.set_if_changed(cx, text.clone());
132      }
133    })
134    .detach();
135    let field = entity.downgrade();
136    cx.observe(signal.entity(), move |observed, cx| {
137      let text = observed.read(cx).clone();
138      field.update(cx, |this, cx| this.sync_text(text, cx)).ok();
139    })
140    .detach();
141  }
142
143  fn sync_text(&mut self, text: String, cx: &mut Context<Self>) {
144    if self.edit.text() != text {
145      self.edit.set_text(&text);
146      cx.notify();
147    }
148  }
149
150  fn adopt(&mut self, index: usize, cx: &mut Context<Self>) {
151    if let Some(text) = self.suggestions.get(index).cloned() {
152      self.edit.set_text(text.as_ref());
153      self.open = false;
154      cx.emit(AutocompleteEvent::Change(text.to_string()));
155      cx.emit(AutocompleteEvent::Commit(text.to_string()));
156      cx.notify();
157    }
158  }
159
160  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
161    if self.disabled {
162      return;
163    }
164    let ks = &event.keystroke;
165    let shown = matches(&self.suggestions, &self.edit.text())
166      .len()
167      .min(self.max_shown);
168    // The list owns the vertical arrows and Enter; everything else is an
169    // ordinary text field.
170    if !ks.modifiers.platform && !ks.modifiers.control {
171      match ks.key.as_str() {
172        "escape" if self.open => {
173          self.open = false;
174          cx.notify();
175          cx.stop_propagation();
176          return;
177        }
178        "down" if self.open && shown > 0 => {
179          self.highlight = (self.highlight + 1) % shown;
180          cx.notify();
181          cx.stop_propagation();
182          return;
183        }
184        "up" if self.open && shown > 0 => {
185          self.highlight = (self.highlight + shown - 1) % shown;
186          cx.notify();
187          cx.stop_propagation();
188          return;
189        }
190        "enter" => {
191          if self.open && shown > 0 {
192            let target = matches(&self.suggestions, &self.edit.text())[self.highlight];
193            self.adopt(target, cx);
194          } else {
195            self.open = false;
196            cx.emit(AutocompleteEvent::Commit(self.text()));
197          }
198          cx.notify();
199          cx.stop_propagation();
200          return;
201        }
202        _ => {}
203      }
204    }
205    match line::keys(self, event, window, cx) {
206      KeyOutcome::Edited => {
207        self.line_changed(cx);
208        cx.stop_propagation();
209      }
210      KeyOutcome::Submit | KeyOutcome::Cancel | KeyOutcome::Pass => {}
211    }
212  }
213}
214
215impl LineEditor for Autocomplete {
216  fn edit(&self) -> &TextEdit {
217    &self.edit
218  }
219
220  fn edit_mut(&mut self) -> &mut TextEdit {
221    &mut self.edit
222  }
223
224  fn line(&self) -> &LineState {
225    &self.state
226  }
227
228  fn line_mut(&mut self) -> &mut LineState {
229    &mut self.state
230  }
231
232  fn line_focus(&self) -> &FocusHandle {
233    &self.focus
234  }
235
236  fn line_read_only(&self) -> bool {
237    self.disabled
238  }
239
240  /// Any edit reopens the list and starts the highlight over, since the
241  /// matches it was pointing at have changed.
242  fn line_changed(&mut self, cx: &mut Context<Self>) {
243    self.open = true;
244    self.highlight = 0;
245    cx.emit(AutocompleteEvent::Change(self.text()));
246    cx.notify();
247  }
248}
249
250line::line_input_handler!(Autocomplete);
251line::line_focus_builders!(Autocomplete);
252
253impl Render for Autocomplete {
254  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
255    let t = theme(cx);
256    let (height, pad_x, font) = control_metrics(self.size);
257    let radius = t.radius(t.default_radius);
258    let focused = self.focus.is_focused(window) && !self.disabled;
259    let surface = t.surface().hsla();
260    let surface_hover = t.surface_hover().hsla();
261    let border = if focused { t.primary() } else { t.border() }.hsla();
262    let text_color = t.text().hsla();
263    let dimmed = t.dimmed().hsla();
264    let highlight_bg = t.primary().alpha(0.12);
265
266    let interior = Line::new(cx.entity()).placeholder(self.placeholder.clone(), dimmed);
267
268    let trigger = line::wire(div().id("guise-autocomplete-trigger"), &self.focus, cx)
269      .on_key_down(cx.listener(Self::on_key))
270      .flex()
271      .items_center()
272      .h(px(height))
273      .px(px(pad_x))
274      .rounded(px(radius))
275      .border_1()
276      .border_color(border)
277      .bg(surface)
278      .text_size(px(font))
279      .line_height(px(font * 1.3))
280      .child(div().flex_1().min_w(px(0.0)).child(interior));
281
282    let mut wrap = div().relative().child(trigger);
283
284    let shown = matches(&self.suggestions, &self.edit.text());
285    if self.open && focused && !shown.is_empty() {
286      let mut menu = div()
287        .absolute()
288        .top(px(height + 6.0))
289        .left(px(0.0))
290        .right(px(0.0))
291        .flex()
292        .flex_col()
293        .gap(px(2.0))
294        .p(px(4.0))
295        .rounded(px(radius))
296        .border_1()
297        .border_color(border)
298        .bg(surface)
299        .shadow_md()
300        .occlude();
301      for (row_ix, &option_ix) in shown.iter().take(self.max_shown).enumerate() {
302        let option = self.suggestions[option_ix].clone();
303        let mut row = div()
304          .id(("guise-autocomplete-option", row_ix))
305          .px(px(10.0))
306          .py(px(6.0))
307          .rounded(px(4.0))
308          .text_size(px(font))
309          .text_color(text_color)
310          .child(option)
311          .on_click(cx.listener(move |this, _ev, _window, cx| {
312            this.adopt(option_ix, cx);
313          }));
314        if row_ix == self.highlight {
315          row = row.bg(highlight_bg);
316        } else {
317          row = row.hover(move |s| s.bg(surface_hover));
318        }
319        menu = menu.child(row);
320      }
321      wrap = wrap.child(deferred(menu));
322    }
323
324    let mut chrome = Field::new().child(if self.disabled {
325      wrap.opacity(0.6)
326    } else {
327      wrap
328    });
329    if let Some(label) = self.label.clone() {
330      chrome = chrome.label(label);
331    }
332    chrome.probe_any("Autocomplete")
333  }
334}
335
336#[cfg(test)]
337mod tests {
338  use super::*;
339
340  fn suggestions() -> Vec<SharedString> {
341    ["Rust", "Ruby", "Python", "TypeScript"]
342      .into_iter()
343      .map(SharedString::new_static)
344      .collect()
345  }
346
347  #[test]
348  fn matching_is_substring_and_case_insensitive() {
349    let s = suggestions();
350    assert_eq!(matches(&s, "ru"), vec![0, 1]);
351    assert_eq!(matches(&s, "PY"), vec![2]);
352    assert_eq!(matches(&s, "script"), vec![3]);
353    assert_eq!(matches(&s, "zzz"), Vec::<usize>::new());
354  }
355
356  #[test]
357  fn empty_query_matches_nothing() {
358    assert_eq!(matches(&suggestions(), ""), Vec::<usize>::new());
359  }
360}