1use 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#[derive(Debug, Clone)]
22pub enum AutocompleteEvent {
23 Change(String),
25 Commit(String),
27}
28
29fn 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
44pub 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 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 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 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 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}