Skip to main content

egui_charts/ui_kit/autocomplete/
mod.rs

1//! Autocomplete Widget
2//!
3//! A text input with autocomplete suggestions.
4//!
5//! # Usage
6//!
7//! ```
8//! use egui_charts::ui_kit::autocomplete::AutocompleteEdit;
9//!
10//! # egui::__run_test_ui(|ui| {
11//! let suggestions = vec!["Apple", "Banana", "Cherry", "Date"];
12//! let mut text = String::new();
13//!
14//! let response = AutocompleteEdit::new(&mut text, suggestions)
15//!     .placeholder("Search fruits...")
16//!     .max_suggestions(5)
17//!     .show(ui);
18//!
19//! if let Some(selected) = response.selected {
20//!     println!("Selected: {}", selected);
21//! }
22//! # });
23//! ```
24
25use 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
34/// Result of showing the autocomplete widget
35pub struct AutocompleteResponse {
36    /// The underlying text edit response
37    pub response: Response,
38    /// The selected suggestion if the user picked one
39    pub selected: Option<String>,
40    /// Whether the dropdown is currently open
41    pub is_open: bool,
42}
43
44/// A text input with autocomplete suggestions
45pub 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    /// Create a new autocomplete edit with the given text and suggestions
56    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    /// Set the placeholder text
71    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
72        self.placeholder = Some(placeholder.into());
73        self
74    }
75
76    /// Set the maximum number of suggestions to show
77    pub fn max_suggestions(mut self, max: usize) -> Self {
78        self.max_suggestions = max;
79        self
80    }
81
82    /// Set whether matching should be case-sensitive
83    pub fn case_sensitive(mut self, sensitive: bool) -> Self {
84        self.case_sensitive = sensitive;
85        self
86    }
87
88    /// Set a unique ID salt for this widget
89    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    /// Filter and sort suggestions based on current text
95    fn filter_suggestions(&self) -> Vec<(String, i32)> {
96        if self.text.is_empty() {
97            // Return all suggestions with score 0 when empty
98            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        // Sort by score (descending)
113        matches.sort_by_key(|m| std::cmp::Reverse(m.1));
114
115        matches.into_iter().take(self.max_suggestions).collect()
116    }
117
118    /// Show the autocomplete widget
119    pub fn show(self, ui: &mut Ui) -> AutocompleteResponse {
120        // Horizontal text inset shared by the input and its suggestions.
121        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        // Get the selected index from memory
129        let mut selected_idx: usize = ui
130            .ctx()
131            .memory(|m| m.data.get_temp(selected_idx_id).unwrap_or(0));
132
133        // Create the text edit
134        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        // Handle keyboard navigation
152        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        // Filter suggestions
162        let filtered = self.filter_suggestions();
163        let has_suggestions = !filtered.is_empty();
164        // The list can shrink without a keystroke, so a stored row may be past its end.
165        selected_idx = selected_idx.min(filtered.len().saturating_sub(1));
166
167        // Determine if popup should be open
168        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        // Handle keyboard navigation
187        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        // Reset selection when text changes
197        if text_changed {
198            selected_idx = 0;
199        }
200
201        let mut selected = None;
202
203        // Show the popup using Area + Frame pattern
204        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                        // The popup, border included, is exactly as wide as the input.
216                        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                        // Handle Enter/Tab to select
286                        if confirm && selected.is_none() {
287                            selected = filtered.get(selected_idx).map(|(s, _)| s.clone());
288                        }
289                    });
290                });
291
292            // Close popup if clicked outside
293            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 a suggestion was selected, update the text
308        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
324/// Helper function to create an autocomplete text input
325pub 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        // Every name contains an `a`, so typing it lists all eight and five arrows reach row 5.
368        // Enter takes the input's focus; the next confirmation explicitly focuses it again.
369        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        // The picked name matches only itself, so the list now has one row.
380        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}