1#![warn(missing_docs)]
2#![warn(clippy::missing_docs_in_private_items)]
3#![doc=include_str!("../README.md")]
4use 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
31type SetTextEditProperties = dyn FnOnce(TextEdit) -> TextEdit;
33
34pub struct AutoCompleteTextEdit<'a, T> {
36 text_field: &'a mut String,
38 search: T,
40 max_suggestions: usize,
42 highlight: bool,
44 multiple_words: bool,
46 set_properties: Option<Box<SetTextEditProperties>>,
48 popup_on_focus: bool,
51 width: f32,
54}
55
56impl<'a, T, S> AutoCompleteTextEdit<'a, T>
57where
58 T: IntoIterator<Item = S>,
59 S: AsRef<str>,
60{
61 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 pub fn max_suggestions(mut self, max_suggestions: usize) -> Self {
86 self.max_suggestions = max_suggestions;
87 self
88 }
89
90 pub fn highlight_matches(mut self, highlight: bool) -> Self {
92 self.highlight = highlight;
93 self
94 }
95
96 pub fn multiple_words(mut self, multiple_words: bool) -> Self {
98 self.multiple_words = multiple_words;
99 self
100 }
101
102 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 pub fn width(mut self, width: f32) -> Self {
112 self.width = width;
113 self
114 }
115
116 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 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 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 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 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 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 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 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 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 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 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
315fn 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 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 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 _ = it.next();
330 } else {
331 break;
332 }
333 }
334 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#[derive(Debug, Clone, Default)]
348#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
349#[cfg_attr(feature = "serde", serde(default))]
350struct AutoCompleteTextEditState {
351 selected_index: Option<usize>,
353 focused: bool,
355 start: usize,
357 end: usize,
359}
360
361impl AutoCompleteTextEditState {
362 fn store(self, ctx: &Context, id: Id) {
364 ctx.data_mut(|d| d.insert_persisted(id, self));
365 }
366
367 fn load(ctx: &Context, id: Id) -> Option<Self> {
369 ctx.data_mut(|d| d.get_persisted(id))
370 }
371
372 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 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 Some(index) if up_pressed => {
393 if index == 0 {
394 None
395 } else {
396 Some(index - 1)
397 }
398 }
399 None if down_pressed => Some(0),
401 None if up_pressed => Some(match_results_count.min(max_suggestions) - 1),
403 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}