garden_gui/
lib.rs

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