1use crate::localization as l10n;
26use crate::theme::ThemeExt;
27use crate::tokens::DESIGN_TOKENS;
28use crate::ui_kit::command_palette::fuzzy_match;
29use egui::{
30 Id, Key, Margin, Popup, Response, Role, ScrollArea, Sense, TextEdit, TextStyle, Ui, Vec2,
31 WidgetInfo,
32};
33
34pub struct AutocompleteResponse {
36 pub response: Response,
38 pub selected: Option<String>,
40 pub is_open: bool,
42}
43
44pub struct AutocompleteEdit<'a> {
46 text: &'a mut String,
47 suggestions: Vec<String>,
48 placeholder: Option<String>,
49 max_suggestions: usize,
50 case_sensitive: bool,
51 id_salt: Option<Id>,
52}
53
54impl<'a> AutocompleteEdit<'a> {
55 pub fn new(
57 text: &'a mut String,
58 suggestions: impl IntoIterator<Item = impl Into<String>>,
59 ) -> Self {
60 Self {
61 text,
62 suggestions: suggestions.into_iter().map(|s| s.into()).collect(),
63 placeholder: None,
64 max_suggestions: 8,
65 case_sensitive: false,
66 id_salt: None,
67 }
68 }
69
70 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
72 self.placeholder = Some(placeholder.into());
73 self
74 }
75
76 pub fn max_suggestions(mut self, max: usize) -> Self {
78 self.max_suggestions = max;
79 self
80 }
81
82 pub fn case_sensitive(mut self, sensitive: bool) -> Self {
84 self.case_sensitive = sensitive;
85 self
86 }
87
88 pub fn id_salt(mut self, id: impl std::hash::Hash + std::fmt::Debug) -> Self {
90 self.id_salt = Some(Id::unique(id));
91 self
92 }
93
94 fn filter_suggestions(&self) -> Vec<(String, i32)> {
96 if self.text.is_empty() {
97 return self
99 .suggestions
100 .iter()
101 .take(self.max_suggestions)
102 .map(|s| (s.clone(), 0))
103 .collect();
104 }
105
106 let mut matches: Vec<_> = self
107 .suggestions
108 .iter()
109 .filter_map(|s| fuzzy_match(self.text, s).map(|m| (s.clone(), m.score)))
110 .collect();
111
112 matches.sort_by_key(|m| std::cmp::Reverse(m.1));
114
115 matches.into_iter().take(self.max_suggestions).collect()
116 }
117
118 pub fn show(self, ui: &mut Ui) -> AutocompleteResponse {
120 let inset = ui.active_theme().spacing.lg;
122 let base_id = self
123 .id_salt
124 .unwrap_or_else(|| ui.scope_id().with("autocomplete"));
125 let popup_id = base_id.with("popup");
126 let selected_idx_id = base_id.with("selected_idx");
127
128 let mut selected_idx: usize = ui
130 .ctx()
131 .memory(|m| m.data.get_temp(selected_idx_id).unwrap_or(0));
132
133 let mut text_edit = TextEdit::singleline(self.text)
135 .desired_width(ui.available_width())
136 .margin(Margin {
137 left: inset as i8,
138 right: inset as i8,
139 ..Margin::symmetric(0, 2)
140 });
141
142 if let Some(ref placeholder) = self.placeholder {
143 text_edit = text_edit.hint_text(l10n::text(ui.ctx(), placeholder));
144 }
145
146 let mut response = ui.add(text_edit);
147 let owns_keyboard =
148 response.has_focus() || ui.memory(|m| m.had_focus_last_frame(response.id));
149 let text_changed = response.changed();
150
151 let (move_up, move_down, confirm, escape) = ui.input(|i| {
153 (
154 owns_keyboard && i.key_pressed(Key::ArrowUp),
155 owns_keyboard && i.key_pressed(Key::ArrowDown),
156 owns_keyboard && (i.key_pressed(Key::Enter) || i.key_pressed(Key::Tab)),
157 owns_keyboard && i.key_pressed(Key::Escape),
158 )
159 });
160
161 let filtered = self.filter_suggestions();
163 let has_suggestions = !filtered.is_empty();
164 selected_idx = selected_idx.min(filtered.len().saturating_sub(1));
166
167 let mut popup_open = Popup::is_id_open(ui.ctx(), popup_id);
169 let should_open = (response.has_focus() || response.gained_focus())
170 && has_suggestions
171 && !self.text.is_empty();
172
173 if should_open && !popup_open {
174 Popup::open_id(ui.ctx(), popup_id);
175 popup_open = true;
176 }
177
178 let pointer_in_popup = ui
179 .memory(|m| m.area_rect(popup_id.with("area")))
180 .is_some_and(|rect| ui.rect_contains_pointer(rect));
181 if escape || !has_suggestions || (!owns_keyboard && !pointer_in_popup) {
182 Popup::close_id(ui.ctx(), popup_id);
183 popup_open = false;
184 }
185
186 if popup_open {
188 if move_up && selected_idx > 0 {
189 selected_idx -= 1;
190 }
191 if move_down && selected_idx + 1 < filtered.len() {
192 selected_idx += 1;
193 }
194 }
195
196 if text_changed {
198 selected_idx = 0;
199 }
200
201 let mut selected = None;
202
203 if popup_open && has_suggestions {
205 Popup::open_id(ui.ctx(), popup_id);
206 let visuals = ui.visuals().clone();
207 let text_x = response.rect.left() + inset;
208 let area_response = egui::Area::new(popup_id.with("area"))
209 .order(egui::Order::Foreground)
210 .fixed_pos(response.rect.left_bottom())
211 .show(ui.ctx(), |ui| {
212 let frame = egui::Frame::popup(ui.style())
213 .inner_margin(Margin::symmetric(0, ui.active_theme().spacing.sm as i8));
214 frame.show(ui, |ui| {
215 ui.set_width(response.rect.width() - 2.0 * frame.stroke.width);
217 ui.set_max_height(200.0);
218 ui.spacing_mut().item_spacing.y = 0.0;
219
220 ScrollArea::vertical().show(ui, |ui| {
221 let rows: Vec<_> = filtered
222 .iter()
223 .map(|_| {
224 ui.allocate_exact_size(
225 Vec2::new(
226 ui.available_width(),
227 DESIGN_TOKENS.sizing.list_item_height,
228 ),
229 Sense::click(),
230 )
231 })
232 .collect();
233 if let Some(idx) =
234 rows.iter().position(|(_, response)| response.hovered())
235 {
236 selected_idx = idx;
237 }
238 for (idx, ((suggestion, _score), (rect, item_response))) in
239 filtered.iter().zip(rows).enumerate()
240 {
241 let is_selected = idx == selected_idx;
242 let translated_label = l10n::text(ui.ctx(), suggestion);
243 item_response.widget_info(|| {
244 WidgetInfo::selected(
245 Role::Button,
246 true,
247 is_selected,
248 &translated_label,
249 )
250 });
251 crate::styles::focus::draw_focus_ring(
252 ui,
253 &item_response,
254 ui.active_theme().radius.sm,
255 );
256
257 if ui.is_rect_visible(rect) {
258 let painter = ui.painter();
259 if is_selected {
260 let fill = visuals.widgets.hovered.bg_fill;
261 painter.rect_filled(
262 rect.shrink2(Vec2::new(
263 ui.active_theme().spacing.sm,
264 0.0,
265 )),
266 ui.active_theme().radius.sm,
267 fill,
268 );
269 }
270 painter.text(
271 egui::pos2(text_x, rect.center().y),
272 egui::Align2::LEFT_CENTER,
273 l10n::text(ui.ctx(), suggestion),
274 TextStyle::Body.resolve(ui.style()),
275 visuals.widgets.hovered.fg_stroke.color,
276 );
277 }
278
279 if item_response.clicked() {
280 selected = Some(suggestion.clone());
281 }
282 }
283 });
284
285 if confirm && selected.is_none() {
287 selected = filtered.get(selected_idx).map(|(s, _)| s.clone());
288 }
289 });
290 });
291
292 if ui.input(|i| i.pointer.any_pressed()) {
294 let pointer_pos = ui.input(|i| i.pointer.interact_pos());
295 if let Some(pos) = pointer_pos
296 && !area_response.response.rect.contains(pos)
297 && !response.rect.contains(pos)
298 {
299 Popup::close_id(ui.ctx(), popup_id);
300 }
301 }
302 }
303
304 ui.ctx()
305 .memory_mut(|m| m.data.insert_temp(selected_idx_id, selected_idx));
306
307 if let Some(ref sel) = selected {
309 *self.text = sel.clone();
310 response.mark_changed();
311 Popup::close_id(ui.ctx(), popup_id);
312 ui.ctx()
313 .memory_mut(|m| m.data.insert_temp(selected_idx_id, 0usize));
314 }
315
316 AutocompleteResponse {
317 response,
318 selected,
319 is_open: Popup::is_id_open(ui.ctx(), popup_id),
320 }
321 }
322}
323
324pub fn autocomplete(
326 ui: &mut Ui,
327 text: &mut String,
328 suggestions: impl IntoIterator<Item = impl Into<String>>,
329) -> AutocompleteResponse {
330 AutocompleteEdit::new(text, suggestions).show(ui)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use egui::{Context, Event, Modifiers, RawInput};
337
338 #[test]
339 fn enter_after_a_pick_from_further_down_keeps_the_picked_text() {
340 let suggestions = [
341 "Apple", "Avocado", "Banana", "Grape", "Papaya", "Guava", "Mango", "Pear",
342 ];
343 let ctx = Context::default();
344 let mut text = String::new();
345 let mut frame = |events: Vec<Event>| {
346 let mut shown = None;
347 let input = RawInput {
348 events,
349 ..Default::default()
350 };
351 ctx.run_ui(input, |ui| {
352 let response = AutocompleteEdit::new(&mut text, suggestions).show(ui);
353 shown = Some((response.response.id, response.selected));
354 })
355 .drop_without_applying_deltas();
356 let (id, selected) = shown.unwrap();
357 (id, selected, text.clone())
358 };
359 let key = |key| Event::Key {
360 key,
361 physical_key: None,
362 pressed: true,
363 repeat: false,
364 modifiers: Modifiers::NONE,
365 };
366
367 let (id, _, _) = frame(vec![]);
370 ctx.memory_mut(|m| m.request_focus(id));
371 frame(vec![Event::Text("a".into())]);
372 for _ in 0..5 {
373 frame(vec![key(Key::ArrowDown)]);
374 }
375 frame(vec![]);
376 let (_, picked, text_after_pick) = frame(vec![key(Key::Enter)]);
377 assert!(picked.is_some());
378
379 ctx.memory_mut(|m| m.request_focus(id));
381 frame(vec![]);
382 let (_, _, text_after_enter) = frame(vec![key(Key::Enter)]);
383 assert_eq!(text_after_enter, text_after_pick);
384 }
385
386 #[test]
387 fn hovering_a_suggestion_moves_the_only_highlight_and_enter_picks_it() {
388 let ctx = Context::default();
389 let mut text = "a".to_owned();
390 let mut input_rect = egui::Rect::NOTHING;
391 let mut time = 0.0;
392 let mut input_id = Id::NULL;
393 let mut frame = |events: Vec<Event>| {
394 let mut picked = None;
395 time += 0.1;
396 let output = ctx.run_ui(
397 RawInput {
398 time: Some(time),
399 screen_rect: Some(egui::Rect::from_min_size(
400 egui::Pos2::ZERO,
401 Vec2::new(1440.0, 900.0),
402 )),
403 events,
404 ..Default::default()
405 },
406 |ui| {
407 let shown = AutocompleteEdit::new(&mut text, ["Apple", "Avocado", "Apricot"])
408 .id_salt("hover")
409 .show(ui);
410 input_rect = shown.response.rect;
411 input_id = shown.response.id;
412 picked = shown.selected;
413 },
414 );
415 (output, picked, input_rect, input_id)
416 };
417 let key = |key| Event::Key {
418 key,
419 physical_key: None,
420 pressed: true,
421 repeat: false,
422 modifiers: Modifiers::NONE,
423 };
424 let (output, _, rect, id) = frame(vec![]);
425 output.drop_without_applying_deltas();
426 ctx.memory_mut(|m| m.request_focus(id));
427 for _ in 0..3 {
428 frame(vec![]).0.drop_without_applying_deltas();
429 }
430 frame(vec![key(Key::ArrowDown)])
431 .0
432 .drop_without_applying_deltas();
433 let pointer = egui::pos2(rect.left() + 20.0, rect.bottom() + 18.0);
434 frame(vec![Event::PointerMoved(pointer)])
435 .0
436 .drop_without_applying_deltas();
437 let (output, _, _, _) = frame(vec![]);
438 let hover = ctx.global_style().visuals.widgets.hovered.bg_fill;
439 let highlights: Vec<_> = output
440 .shapes
441 .iter()
442 .filter_map(|s| match &s.shape {
443 egui::Shape::Rect(r) if r.fill == hover => Some(r.clone()),
444 _ => None,
445 })
446 .collect();
447 output.drop_without_applying_deltas();
448 assert_eq!(highlights.len(), 1);
449 assert_eq!(
450 highlights[0].corner_radius,
451 egui::CornerRadius::from(ctx.active_theme().radius.sm)
452 );
453 assert!(highlights[0].rect.left() > rect.left() + 1.0);
454 let (output, picked, _, _) = frame(vec![key(Key::Enter)]);
455 output.drop_without_applying_deltas();
456 assert_eq!(picked.as_deref(), Some("Apple"));
457 }
458
459 #[test]
460 fn a_popup_does_not_handle_keys_after_focus_moves_to_another_field() {
461 let ctx = Context::default();
462 let mut text = "a".to_owned();
463 let mut other = String::new();
464 let mut frame = |events| {
465 let mut shown = None;
466 let mut other_id = Id::NULL;
467 ctx.run_ui(
468 RawInput {
469 events,
470 ..Default::default()
471 },
472 |ui| {
473 shown = Some(AutocompleteEdit::new(&mut text, ["Apple", "Avocado"]).show(ui));
474 other_id = ui.text_edit_singleline(&mut other).id;
475 },
476 )
477 .drop_without_applying_deltas();
478 (shown.unwrap(), other_id, text.clone())
479 };
480 let (shown, other_id, _) = frame(vec![]);
481 ctx.memory_mut(|m| m.request_focus(shown.response.id));
482 assert!(frame(vec![]).0.is_open);
483 ctx.memory_mut(|m| m.request_focus(other_id));
484 let (shown, _, text) = frame(vec![Event::Key {
485 key: Key::Enter,
486 physical_key: None,
487 pressed: true,
488 repeat: false,
489 modifiers: Modifiers::NONE,
490 }]);
491 assert!(shown.selected.is_none());
492 assert!(!shown.is_open);
493 assert_eq!(text, "a");
494 }
495}