1use 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
51type SetTextEditProperties = dyn FnOnce(TextEdit) -> TextEdit;
53
54pub struct AutoCompleteTextEdit<'a, T> {
56 text_field: &'a mut String,
58 search: T,
60 max_suggestions: usize,
62 highlight: bool,
64 set_properties: Option<Box<SetTextEditProperties>>,
66 multiple_words: bool,
68}
69
70impl<'a, T, S> AutoCompleteTextEdit<'a, T>
71where
72 T: IntoIterator<Item = S>,
73 S: AsRef<str>,
74{
75 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 pub fn max_suggestions(mut self, max_suggestions: usize) -> Self {
98 self.max_suggestions = max_suggestions;
99 self
100 }
101 pub fn highlight_matches(mut self, highlight: bool) -> Self {
103 self.highlight = highlight;
104 self
105 }
106 pub fn multiple_words(mut self, multiple_words: bool) -> Self {
108 self.multiple_words = multiple_words;
109 self
110 }
111
112 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 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 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 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 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 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 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 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
309fn 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 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 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 _ = it.next();
324 } else {
325 break;
326 }
327 }
328 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#[derive(Clone, Default)]
342#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
343#[cfg_attr(feature = "serde", serde(default))]
344struct AutoCompleteTextEditState {
345 selected_index: Option<usize>,
347 focused: bool,
349 start: usize,
351 end: usize,
353}
354
355impl AutoCompleteTextEditState {
356 fn store(self, ctx: &Context, id: Id) {
358 ctx.data_mut(|d| d.insert_persisted(id, self));
359 }
360
361 fn load(ctx: &Context, id: Id) -> Option<Self> {
363 ctx.data_mut(|d| d.get_persisted(id))
364 }
365
366 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 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 Some(index) if up_pressed => {
385 if index == 0 {
386 None
387 } else {
388 Some(index - 1)
389 }
390 }
391 None if down_pressed => Some(0),
393 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}