#![warn(missing_docs)]
#![warn(clippy::missing_docs_in_private_items)]
#![doc=include_str!("../README.md")]
use egui::{text::LayoutJob, Context, FontId, Id, Key, Modifiers, TextBuffer, TextEdit, Widget};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use std::cmp::{min, Reverse};
type SetTextEditProperties = dyn FnOnce(TextEdit) -> TextEdit;
pub struct AutoCompleteTextEdit<'a, T> {
text_field: &'a mut String,
search: T,
max_suggestions: usize,
highlight: bool,
set_properties: Option<Box<SetTextEditProperties>>,
}
impl<'a, T, S> AutoCompleteTextEdit<'a, T>
where
T: IntoIterator<Item = S>,
S: AsRef<str>,
{
pub fn new(text_field: &'a mut String, search: T) -> Self {
Self {
text_field,
search,
max_suggestions: 10,
highlight: false,
set_properties: None,
}
}
}
impl<'a, T, S> AutoCompleteTextEdit<'a, T>
where
T: IntoIterator<Item = S>,
S: AsRef<str>,
{
pub fn max_suggestions(mut self, max_suggestions: usize) -> Self {
self.max_suggestions = max_suggestions;
self
}
pub fn highlight_matches(mut self, highlight: bool) -> Self {
self.highlight = highlight;
self
}
pub fn set_text_edit_properties(
mut self,
set_properties: impl FnOnce(TextEdit) -> TextEdit + 'static,
) -> Self {
self.set_properties = Some(Box::new(set_properties));
self
}
}
impl<'a, T, S> Widget for AutoCompleteTextEdit<'a, T>
where
T: IntoIterator<Item = S>,
S: AsRef<str>,
{
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let Self {
text_field,
search,
max_suggestions,
highlight,
set_properties,
} = self;
let id = ui.next_auto_id();
ui.skip_ahead_auto_ids(1);
let mut state = AutoCompleteTextEditState::load(ui.ctx(), id).unwrap_or_default();
let up_pressed = state.focused
&& ui.input_mut(|input| input.consume_key(Modifiers::default(), Key::ArrowUp));
let down_pressed = state.focused
&& ui.input_mut(|input| input.consume_key(Modifiers::default(), Key::ArrowDown));
let mut text_edit = TextEdit::singleline(text_field);
if let Some(set_properties) = set_properties {
text_edit = set_properties(text_edit);
}
let text_response = text_edit.ui(ui);
state.focused = text_response.has_focus();
let matcher = SkimMatcherV2::default().ignore_case();
let mut match_results = search
.into_iter()
.filter_map(|s| {
let score = matcher.fuzzy_indices(s.as_ref(), text_field.as_str());
score.map(|(score, indices)| (s, score, indices))
})
.collect::<Vec<_>>();
match_results.sort_by_key(|k| Reverse(k.1));
if text_response.changed()
|| (state.selected_index.is_some()
&& state.selected_index.unwrap() >= match_results.len())
{
state.selected_index = None;
}
state.update_index(
down_pressed,
up_pressed,
match_results.len(),
max_suggestions,
);
let accepted_by_keyboard = ui.input_mut(|input| input.key_pressed(Key::Enter))
|| ui.input_mut(|input| input.key_pressed(Key::Tab));
if let (Some(index), true) = (
state.selected_index,
ui.memory(|mem| mem.is_popup_open(id)) && accepted_by_keyboard,
) {
text_field.replace_with(match_results[index].0.as_ref())
}
egui::popup::popup_below_widget(ui, id, &text_response, |ui| {
for (i, (output, _, match_indices)) in
match_results.iter().take(max_suggestions).enumerate()
{
let mut selected = if let Some(x) = state.selected_index {
x == i
} else {
false
};
let text = if highlight {
highlight_matches(
output.as_ref(),
match_indices,
ui.style().visuals.widgets.active.text_color(),
)
} else {
let mut job = LayoutJob::default();
job.append(output.as_ref(), 0.0, egui::TextFormat::default());
job
};
if ui.toggle_value(&mut selected, text).clicked() {
text_field.replace_with(output.as_ref());
}
}
});
if !text_field.as_str().is_empty() && text_response.has_focus() && !match_results.is_empty()
{
ui.memory_mut(|mem| mem.open_popup(id));
} else {
ui.memory_mut(|mem| {
if mem.is_popup_open(id) {
mem.close_popup()
}
});
}
state.store(ui.ctx(), id);
text_response
}
}
fn highlight_matches(text: &str, match_indices: &[usize], color: egui::Color32) -> LayoutJob {
let mut formatted = LayoutJob::default();
let mut it = text.char_indices().enumerate().peekable();
while let Some((char_idx, (byte_idx, c))) = it.next() {
let start = byte_idx;
let mut end = byte_idx + (c.len_utf8() - 1);
let match_state = match_indices.contains(&char_idx);
while let Some((peek_char_idx, (_, k))) = it.peek() {
if match_state == match_indices.contains(peek_char_idx) {
end += k.len_utf8();
_ = it.next();
} else {
break;
}
}
let format = if match_state {
egui::TextFormat::simple(FontId::default(), color)
} else {
egui::TextFormat::default()
};
let slice = &text[start..=end];
formatted.append(slice, 0.0, format);
}
formatted
}
#[derive(Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
struct AutoCompleteTextEditState {
selected_index: Option<usize>,
focused: bool,
}
impl AutoCompleteTextEditState {
fn store(self, ctx: &Context, id: Id) {
ctx.data_mut(|d| d.insert_persisted(id, self));
}
fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data_mut(|d| d.get_persisted(id))
}
fn update_index(
&mut self,
down_pressed: bool,
up_pressed: bool,
match_results_count: usize,
max_suggestions: usize,
) {
self.selected_index = match self.selected_index {
Some(index) if down_pressed => {
if index + 1 < min(match_results_count, max_suggestions) {
Some(index + 1)
} else {
Some(index)
}
}
Some(index) if up_pressed => {
if index == 0 {
None
} else {
Some(index - 1)
}
}
None if down_pressed => Some(0),
Some(index) => Some(index),
None => None,
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn increment_index() {
let mut state = AutoCompleteTextEditState::default();
assert_eq!(None, state.selected_index);
state.update_index(false, false, 10, 10);
assert_eq!(None, state.selected_index);
state.update_index(true, false, 10, 10);
assert_eq!(Some(0), state.selected_index);
state.update_index(true, false, 2, 3);
assert_eq!(Some(1), state.selected_index);
state.update_index(true, false, 2, 3);
assert_eq!(Some(1), state.selected_index);
state.update_index(true, false, 10, 3);
assert_eq!(Some(2), state.selected_index);
state.update_index(true, false, 10, 3);
assert_eq!(Some(2), state.selected_index);
}
#[test]
fn decrement_index() {
let mut state = AutoCompleteTextEditState {
selected_index: Some(1),
..Default::default()
};
state.selected_index = Some(1);
state.update_index(false, false, 10, 10);
assert_eq!(Some(1), state.selected_index);
state.update_index(false, true, 10, 10);
assert_eq!(Some(0), state.selected_index);
state.update_index(false, true, 10, 10);
assert_eq!(None, state.selected_index);
}
#[test]
fn highlight() {
let text = String::from("Test123áéíó");
let match_indices = vec![1, 5, 6, 8, 9, 10];
let layout = highlight_matches(&text, &match_indices, egui::Color32::RED);
assert_eq!(6, layout.sections.len());
let sec1 = layout.sections.first().unwrap();
assert_eq!(&text[sec1.byte_range.start..sec1.byte_range.end], "T");
assert_ne!(sec1.format.color, egui::Color32::RED);
let sec2 = layout.sections.get(1).unwrap();
assert_eq!(&text[sec2.byte_range.start..sec2.byte_range.end], "e");
assert_eq!(sec2.format.color, egui::Color32::RED);
let sec3 = layout.sections.get(2).unwrap();
assert_eq!(&text[sec3.byte_range.start..sec3.byte_range.end], "st1");
assert_ne!(sec3.format.color, egui::Color32::RED);
let sec4 = layout.sections.get(3).unwrap();
assert_eq!(&text[sec4.byte_range.start..sec4.byte_range.end], "23");
assert_eq!(sec4.format.color, egui::Color32::RED);
let sec5 = layout.sections.get(4).unwrap();
assert_eq!(&text[sec5.byte_range.start..sec5.byte_range.end], "á");
assert_ne!(sec5.format.color, egui::Color32::RED);
let sec6 = layout.sections.get(5).unwrap();
assert_eq!(&text[sec6.byte_range.start..sec6.byte_range.end], "éíó");
assert_eq!(sec6.format.color, egui::Color32::RED);
}
}