1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
use crate::UiExt as _;
/// Text edit with autocomplete suggestions popup.
///
/// Shows an editable `text_buffer` with matching entries from `suggestions`
/// as selectable options in a popup below the text edit.
///
/// `hint_text` is an optional placeholder text shown when the buffer is empty.
///
/// `invalid_hint_text` can be used to highlight invalid input and show an info
/// label with the text on top of the suggestion list. Input validation itself
/// has to be done outside of this function.
///
/// Leading whitespace in a suggestion is treated as display-only indentation:
/// it's kept as indentation in the popup, but stripped for filtering and when
/// the suggestion is written back into the buffer.
pub fn autocomplete_text_edit(
ui: &mut egui::Ui,
text_buffer: &mut dyn egui::TextBuffer,
suggestions: &[String],
empty_hint_text: Option<impl Into<egui::WidgetText>>,
invalid_hint_text: Option<impl Into<String>>,
) -> egui::Response {
// Grow with the available width instead of egui's default fixed `text_edit_width` cap.
let mut text_edit = egui::TextEdit::singleline(text_buffer).desired_width(ui.available_width());
if let Some(hint) = empty_hint_text {
text_edit = text_edit.hint_text(hint);
}
let mut response = ui
.scope(|ui| {
if invalid_hint_text.is_some() {
ui.style_invalid_field();
text_edit = text_edit.text_color(ui.visuals().error_fg_color);
}
ui.add(text_edit)
})
.inner;
// Filter suggestions based on current text input.
let filtered_suggestions: Vec<_> = suggestions
.iter()
.filter(|suggestion| {
// Trim leading whitespace for input matching,
// but keep it for visually indenting the suggestions.
let value = suggestion.trim_start();
value.starts_with(text_buffer.as_str()) && value != text_buffer.as_str()
})
.collect();
let num_suggestions = filtered_suggestions.len();
// In addition to mouse, allow also to select suggestions with up/down arrow keys and Enter.
let (index_delta, enter_pressed) = ui.input(|i| {
let delta =
i.key_pressed(egui::Key::ArrowDown) as i32 - i.key_pressed(egui::Key::ArrowUp) as i32;
(delta, i.key_pressed(egui::Key::Enter))
});
let suggestions_open = (response.has_focus() || response.lost_focus() || index_delta != 0)
&& (num_suggestions > 0 || invalid_hint_text.is_some());
// Persist the selected index using egui's temporary data storage if the suggestions popup is open.
let selected_index: Option<usize> = if suggestions_open {
let previous_index = ui.data(|d| d.get_temp::<usize>(response.id));
let index = if index_delta == 0 {
previous_index
} else {
// (prev + n + delta) % n handles both directions correctly.
let base = previous_index.unwrap_or(if index_delta > 0 { usize::MAX } else { 0 });
Some(
(base
.wrapping_add(num_suggestions)
.wrapping_add_signed(index_delta as isize))
% num_suggestions,
)
};
if let Some(i) = index {
ui.data_mut(|d| d.insert_temp(response.id, i));
}
index
} else {
ui.data_mut(|d| d.remove::<usize>(response.id));
None
};
// If enter was pressed, confirm the selection and don't show the suggestion popup.
if enter_pressed
&& let Some(idx) = selected_index
&& let Some(suggestion) = filtered_suggestions.get(idx)
{
text_buffer.replace_with(suggestion.trim_start());
response.mark_changed();
return response;
}
let width = response.rect.width();
let mut changed = false;
let suggestions_ui = |ui: &mut egui::Ui| {
for (idx, suggestion) in filtered_suggestions.iter().enumerate() {
let is_selected = selected_index == Some(idx);
// Keep any leading indentation, then highlight the matched prefix against the completion.
let value = suggestion.trim_start();
let indent = &suggestion[..suggestion.len() - value.len()];
let completion = value.strip_prefix(text_buffer.as_str()).unwrap_or("");
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
let mut layout_job = egui::text::LayoutJob::default();
// Already typed part of the suggestion: "highlighted" as normal text.
layout_job.append(
&format!("{indent}{}", text_buffer.as_str()),
0.0,
egui::TextFormat::simple(body.clone(), ui.tokens().text_default),
);
// Completion remainder of the suggestion: subdued.
layout_job.append(
completion,
0.0,
egui::TextFormat::simple(body, ui.tokens().text_subdued),
);
let button = egui::Button::new(layout_job)
.min_size(egui::vec2(width, 0.0))
.selected(is_selected);
let button_response = ui.add(button);
if is_selected {
// Make sure the selected item is visible also when using up/down keys.
button_response.scroll_to_me(Some(egui::Align::Center));
}
if button_response.clicked() {
changed = true;
text_buffer.replace_with(value);
}
}
};
egui::Popup::from_response(&response)
.style(crate::menu::menu_style())
.open(suggestions_open)
.show(|ui: &mut egui::Ui| {
ui.set_width(width);
// Show hint for invalid input always on top of the suggestions.
if let Some(invalid_hint_text) = invalid_hint_text.map(Into::into) {
ui.info_label(invalid_hint_text);
if num_suggestions > 0 {
ui.add_space(ui.spacing().item_spacing.y);
}
}
egui::ScrollArea::vertical()
.min_scrolled_height(350.0)
.max_height(350.0)
.show(ui, suggestions_ui);
});
if changed {
response.mark_changed();
}
response
}