Skip to main content

egui_autocomplete/
lib.rs

1#![warn(missing_docs)]
2#![warn(clippy::missing_docs_in_private_items)]
3#![doc=include_str!("../README.md")]
4//! # Example
5//! ```rust
6//! use egui_autocomplete::AutoCompleteTextEdit;
7//! struct AutoCompleteExample {
8//!   // User entered text
9//!   text: String,
10//!   // A list of strings to search for completions
11//!   inputs: Vec<String>,
12//! }
13//!
14//! impl AutoCompleteExample {
15//!   fn update(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) {
16//!     ui.add(AutoCompleteTextEdit::new(
17//!        &mut self.text,
18//!        &self.inputs,
19//!     ));
20//!   }
21//! }
22//! ````
23use egui::{
24    text::LayoutJob, Context, FontId, Id, Key, Modifiers, Popup, PopupCloseBehavior, TextBuffer,
25    TextEdit, Widget,
26};
27use fuzzy_matcher::skim::SkimMatcherV2;
28use fuzzy_matcher::FuzzyMatcher;
29use std::cmp::Reverse;
30
31/// Trait that can be used to modify the TextEdit
32type SetTextEditProperties = dyn FnOnce(TextEdit) -> TextEdit;
33
34/// An extension to the [`egui::TextEdit`] that allows for a dropdown box with autocomplete to popup while typing.
35pub struct AutoCompleteTextEdit<'a, T> {
36    /// Contents of text edit passed into [`egui::TextEdit`]
37    text_field: &'a mut String,
38    /// Data to use as the search term
39    search: T,
40    /// A limit that can be placed on the maximum number of autocomplete suggestions shown
41    max_suggestions: usize,
42    /// If true, highlights the matching indices in the dropdown
43    highlight: bool,
44    /// If true, provide completions when entering multiple space-delimited words
45    multiple_words: bool,
46    /// Used to set properties on the internal TextEdit
47    set_properties: Option<Box<SetTextEditProperties>>,
48    /// If set to true, the popup will show up when focused instead of waiting for a character to
49    /// be typed
50    popup_on_focus: bool,
51    /// Width of the autocomplete list
52    /// Defaults to all available space
53    width: f32,
54}
55
56impl<'a, T, S> AutoCompleteTextEdit<'a, T>
57where
58    T: IntoIterator<Item = S>,
59    S: AsRef<str>,
60{
61    /// Creates a new [`AutoCompleteTextEdit`].
62    ///
63    /// `text_field` - Contents of the text edit passed into [`egui::TextEdit`]
64    /// `search` - Data use as the search term
65    pub fn new(text_field: &'a mut String, search: T) -> Self {
66        Self {
67            text_field,
68            search,
69            max_suggestions: 10,
70            highlight: false,
71            multiple_words: false,
72            set_properties: None,
73            popup_on_focus: false,
74            width: f32::INFINITY,
75        }
76    }
77}
78
79impl<T, S> AutoCompleteTextEdit<'_, T>
80where
81    T: IntoIterator<Item = S>,
82    S: AsRef<str>,
83{
84    /// This determines the number of options appear in the dropdown menu
85    pub fn max_suggestions(mut self, max_suggestions: usize) -> Self {
86        self.max_suggestions = max_suggestions;
87        self
88    }
89
90    /// If set to true, characters will be highlighted in the dropdown to show the match
91    pub fn highlight_matches(mut self, highlight: bool) -> Self {
92        self.highlight = highlight;
93        self
94    }
95
96    /// If set to true, completions will be provided when entering multiple words.
97    pub fn multiple_words(mut self, multiple_words: bool) -> Self {
98        self.multiple_words = multiple_words;
99        self
100    }
101
102    /// If set to true, the popup will show up when focused instead of waiting for a character to
103    /// be typed
104    pub fn popup_on_focus(mut self, popup_on_focus: bool) -> Self {
105        self.popup_on_focus = popup_on_focus;
106        self
107    }
108
109    /// This determines the width of the popup autocomplete list
110    /// defaults to all available space
111    pub fn width(mut self, width: f32) -> Self {
112        self.width = width;
113        self
114    }
115
116    /// Can be used to set the properties of the internal [`egui::TextEdit`]
117    /// # Example
118    /// ```rust
119    /// # use egui_autocomplete::AutoCompleteTextEdit;
120    /// # fn make_text_edit(mut search_field: String, inputs: Vec<String>) {
121    /// AutoCompleteTextEdit::new(&mut search_field, &inputs)
122    ///     .set_text_edit_properties(|text_edit: egui::TextEdit<'_>| {
123    ///         text_edit
124    ///             .hint_text("Hint Text")
125    ///             .text_color(egui::Color32::RED)
126    ///     });
127    /// # }
128    /// ```
129    pub fn set_text_edit_properties(
130        mut self,
131        set_properties: impl FnOnce(TextEdit) -> TextEdit + 'static,
132    ) -> Self {
133        self.set_properties = Some(Box::new(set_properties));
134        self
135    }
136}
137
138impl<T, S> Widget for AutoCompleteTextEdit<'_, T>
139where
140    T: IntoIterator<Item = S>,
141    S: AsRef<str>,
142{
143    /// The response returned is the response from the internal text_edit
144    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
145        let Self {
146            text_field,
147            search,
148            max_suggestions,
149            highlight,
150            multiple_words,
151            set_properties,
152            popup_on_focus,
153            width,
154        } = self;
155
156        let id = ui.next_auto_id();
157        ui.skip_ahead_auto_ids(1);
158        let mut state = AutoCompleteTextEditState::load(ui.ctx(), id).unwrap_or_default();
159
160        // only consume up/down presses if the text box is focused. This overwrites default behavior
161        // to move to start/end of the string
162        let up_pressed = state.focused
163            && ui.input_mut(|input| input.consume_key(Modifiers::default(), Key::ArrowUp));
164        let down_pressed = state.focused
165            && ui.input_mut(|input| input.consume_key(Modifiers::default(), Key::ArrowDown));
166
167        let mut text_edit = TextEdit::singleline(text_field);
168        if let Some(set_properties) = set_properties {
169            text_edit = set_properties(text_edit);
170        }
171        let text_edit_output = text_edit.show(ui);
172
173        let completion_input = if multiple_words {
174            if let Some(cursor_range) = text_edit_output.cursor_range {
175                let index = cursor_range.primary.index;
176                // Get the word located at the current index
177                let mut start = index;
178                let mut end = index;
179                while start > 0
180                    && !text_field[start - 1..start]
181                        .chars()
182                        .next()
183                        .map(|c| c.is_whitespace())
184                        .unwrap_or(false)
185                {
186                    start -= 1;
187                }
188                while end < text_field.len()
189                    && !text_field[end..end + 1]
190                        .chars()
191                        .next()
192                        .map(|c| c.is_whitespace())
193                        .unwrap_or(false)
194                {
195                    end += 1;
196                }
197                state.start = start;
198                state.end = end;
199                text_field[start..end].trim()
200            } else {
201                text_field.as_str()
202            }
203        } else {
204            text_field.as_str()
205        };
206
207        let mut text_response = text_edit_output.response;
208        state.focused = text_response.has_focus();
209
210        let matcher = SkimMatcherV2::default().ignore_case();
211
212        let match_results = {
213            let mut match_results = search
214                .into_iter()
215                .filter_map(|s| {
216                    let score = matcher.fuzzy_indices(s.as_ref(), completion_input);
217                    score.map(|(score, indices)| (s, score, indices))
218                })
219                .collect::<Vec<_>>();
220            match_results.sort_by_key(|k| Reverse(k.1));
221            match_results
222        };
223
224        if text_response.changed()
225            || (state.selected_index.is_some()
226                && state.selected_index.unwrap() >= match_results.len())
227        {
228            state.selected_index = None;
229        }
230
231        state.update_index(
232            down_pressed,
233            up_pressed,
234            match_results.len(),
235            max_suggestions,
236        );
237
238        // create the popup object
239        let popup = Popup::from_response(&text_response)
240            .layout(egui::Layout::top_down_justified(egui::Align::LEFT))
241            .close_behavior(PopupCloseBehavior::IgnoreClicks)
242            .id(id)
243            .align(egui::RectAlign::BOTTOM_START)
244            .width(width)
245            .open(
246                state.focused
247                    && (!text_field.is_empty() || popup_on_focus)
248                    && !match_results.is_empty(),
249            );
250
251        // act on accepting key presses
252        let accepted_by_keyboard = ui.input(|input| input.key_pressed(Key::Enter))
253            || ui.input(|input| input.key_pressed(Key::Tab));
254        if let (Some(index), true) = (
255            state.selected_index,
256            // If accepted by keyboard, close the popup. If the popup is closed with a selected index, take that text
257            accepted_by_keyboard || !popup.is_open(),
258        ) {
259            let match_result = match_results[index].0.as_ref();
260            if multiple_words {
261                text_field.replace_range(state.start..state.end, match_result);
262                // Move the cursor to the end of the line.
263                let text_edit_id = text_response.id;
264                if let Some(mut state) = TextEdit::load_state(ui.ctx(), text_edit_id) {
265                    let ccursor = egui::text::CCursor::new(text_field.chars().count());
266                    state
267                        .cursor
268                        .set_char_range(Some(egui::text::CCursorRange::one(ccursor)));
269                    state.store(ui.ctx(), text_edit_id);
270                    // Give focus back to the text edit.
271                    text_response.request_focus();
272                }
273            } else {
274                text_field.replace_with(match_result);
275            }
276            state.selected_index = None;
277            text_response.mark_changed();
278        }
279
280        // show the popup
281        popup.show(|ui| {
282            for (i, (output, _, match_indices)) in
283                match_results.iter().take(max_suggestions).enumerate()
284            {
285                let mut selected = if let Some(x) = state.selected_index {
286                    x == i
287                } else {
288                    false
289                };
290
291                let text = if highlight {
292                    highlight_matches(
293                        output.as_ref(),
294                        match_indices,
295                        ui.style().visuals.widgets.active.text_color(),
296                    )
297                } else {
298                    let mut job = LayoutJob::default();
299                    job.append(output.as_ref(), 0.0, egui::TextFormat::default());
300                    job
301                };
302                //  Update selected index based on hover
303                if ui.toggle_value(&mut selected, text).hovered() {
304                    state.selected_index = Some(i);
305                }
306            }
307        });
308
309        state.store(ui.ctx(), id);
310
311        text_response
312    }
313}
314
315/// Highlights all the match indices in the provided text
316fn highlight_matches(text: &str, match_indices: &[usize], color: egui::Color32) -> LayoutJob {
317    let mut formatted = LayoutJob::default();
318    let mut it = text.char_indices().enumerate().peekable();
319    // Iterate through all indices in the string
320    while let Some((char_idx, (byte_idx, c))) = it.next() {
321        let start = byte_idx;
322        let mut end = byte_idx + (c.len_utf8() - 1);
323        let match_state = match_indices.contains(&char_idx);
324        // Find all consecutive characters that have the same state
325        while let Some((peek_char_idx, (_, k))) = it.peek() {
326            if match_state == match_indices.contains(peek_char_idx) {
327                end += k.len_utf8();
328                // Advance the iterator, we already peeked the value so it is fine to ignore
329                _ = it.next();
330            } else {
331                break;
332            }
333        }
334        // Format current slice based on the state
335        let format = if match_state {
336            egui::TextFormat::simple(FontId::default(), color)
337        } else {
338            egui::TextFormat::default()
339        };
340        let slice = &text[start..=end];
341        formatted.append(slice, 0.0, format);
342    }
343    formatted
344}
345
346/// Stores the currently selected index in egui state
347#[derive(Debug, Clone, Default)]
348#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
349#[cfg_attr(feature = "serde", serde(default))]
350struct AutoCompleteTextEditState {
351    /// Currently selected index, is `None` if nothing is selected
352    selected_index: Option<usize>,
353    /// Whether or not the text edit was focused last frame
354    focused: bool,
355    /// The start of the current word being replaced
356    start: usize,
357    /// The end of the current word being replaced
358    end: usize,
359}
360
361impl AutoCompleteTextEditState {
362    /// Store the state with egui
363    fn store(self, ctx: &Context, id: Id) {
364        ctx.data_mut(|d| d.insert_persisted(id, self));
365    }
366
367    /// Get the state from egui if it exists
368    fn load(ctx: &Context, id: Id) -> Option<Self> {
369        ctx.data_mut(|d| d.get_persisted(id))
370    }
371
372    /// Updates in selected index, checks to make sure nothing goes out of bounds
373    fn update_index(
374        &mut self,
375        down_pressed: bool,
376        up_pressed: bool,
377        match_results_count: usize,
378        max_suggestions: usize,
379    ) {
380        self.selected_index = match self.selected_index {
381            _ if match_results_count == 0 || max_suggestions == 0 => None,
382            // Increment selected index when down is pressed, limit it to the number of matches and max_suggestions
383            // Deselect if at last index
384            Some(index) if down_pressed => {
385                if index + 1 < match_results_count.min(max_suggestions) {
386                    Some(index + 1)
387                } else {
388                    None
389                }
390            }
391            // Decrement selected index if up is pressed. Deselect if at first index
392            Some(index) if up_pressed => {
393                if index == 0 {
394                    None
395                } else {
396                    Some(index - 1)
397                }
398            }
399            // If nothing is selected and down is pressed, select first item
400            None if down_pressed => Some(0),
401            // If nothing is selected and up is pressed, select last item
402            None if up_pressed => Some(match_results_count.min(max_suggestions) - 1),
403            // Do nothing if no keys are pressed
404            Some(index) => Some(index),
405            None => None,
406        }
407    }
408}
409
410#[cfg(test)]
411mod test {
412    use super::*;
413
414    #[test]
415    fn increment_index() {
416        let mut state = AutoCompleteTextEditState::default();
417        assert_eq!(None, state.selected_index);
418        state.update_index(false, false, 10, 10);
419        assert_eq!(None, state.selected_index);
420        state.update_index(true, false, 10, 10);
421        assert_eq!(Some(0), state.selected_index);
422        state.update_index(true, false, 2, 3);
423        assert_eq!(Some(1), state.selected_index);
424        state.update_index(true, false, 2, 3);
425        assert_eq!(None, state.selected_index);
426        state.update_index(true, false, 10, 3);
427        assert_eq!(Some(0), state.selected_index);
428        state.update_index(true, false, 10, 3);
429        state.update_index(true, false, 10, 3);
430        assert_eq!(Some(2), state.selected_index);
431        state.update_index(true, false, 10, 3);
432        assert_eq!(None, state.selected_index);
433        state.update_index(false, true, 10, 3);
434        assert_eq!(Some(2), state.selected_index);
435    }
436    #[test]
437    fn decrement_index() {
438        let mut state = AutoCompleteTextEditState {
439            selected_index: Some(1),
440            ..Default::default()
441        };
442        state.selected_index = Some(1);
443        state.update_index(false, false, 10, 10);
444        assert_eq!(Some(1), state.selected_index);
445        state.update_index(false, true, 10, 10);
446        assert_eq!(Some(0), state.selected_index);
447        state.update_index(false, true, 10, 10);
448        assert_eq!(None, state.selected_index);
449    }
450    #[test]
451    fn highlight() {
452        let text = String::from("Test123áéíó");
453        let match_indices = vec![1, 5, 6, 8, 9, 10];
454        let layout = highlight_matches(&text, &match_indices, egui::Color32::RED);
455        assert_eq!(6, layout.sections.len());
456        let sec1 = layout.sections.first().unwrap();
457        assert_eq!(&text[sec1.byte_range.start..sec1.byte_range.end], "T");
458        assert_ne!(sec1.format.color, egui::Color32::RED);
459
460        let sec2 = layout.sections.get(1).unwrap();
461        assert_eq!(&text[sec2.byte_range.start..sec2.byte_range.end], "e");
462        assert_eq!(sec2.format.color, egui::Color32::RED);
463
464        let sec3 = layout.sections.get(2).unwrap();
465        assert_eq!(&text[sec3.byte_range.start..sec3.byte_range.end], "st1");
466        assert_ne!(sec3.format.color, egui::Color32::RED);
467
468        let sec4 = layout.sections.get(3).unwrap();
469        assert_eq!(&text[sec4.byte_range.start..sec4.byte_range.end], "23");
470        assert_eq!(sec4.format.color, egui::Color32::RED);
471
472        let sec5 = layout.sections.get(4).unwrap();
473        assert_eq!(&text[sec5.byte_range.start..sec5.byte_range.end], "á");
474        assert_ne!(sec5.format.color, egui::Color32::RED);
475
476        let sec6 = layout.sections.get(5).unwrap();
477        assert_eq!(&text[sec6.byte_range.start..sec6.byte_range.end], "éíó");
478        assert_eq!(sec6.format.color, egui::Color32::RED);
479    }
480}