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}