1use crate::bind::{Bind, CURR_FRAME, MaybeSend, State};
4
5#[derive(Clone)]
6struct AsyncSearchState {
7 last_typed: f64,
8 last_query: String,
9}
10
11#[must_use = "You should call .show() on this widget to render it"]
17pub struct AsyncSearch<'a, T, E> {
18 bind: &'a mut Bind<Vec<T>, E>,
19 query: &'a mut String,
20 debounce_secs: f64,
21 hint_text: String,
22 retain_previous_results: bool,
23 wrap_results: bool,
24 width: Option<f32>,
25 popup_width: Option<f32>,
26}
27
28impl<'a, T, E> AsyncSearch<'a, T, E> {
29 pub fn new(bind: &'a mut Bind<Vec<T>, E>, query: &'a mut String) -> Self {
31 Self {
32 bind,
33 query,
34 debounce_secs: 0.5,
35 hint_text: "Search...".to_string(),
36 retain_previous_results: true,
37 wrap_results: false,
38 width: None,
39 popup_width: None,
40 }
41 }
42
43 pub const fn debounce_secs(mut self, secs: f64) -> Self {
45 self.debounce_secs = secs;
46 self
47 }
48
49 pub fn hint_text(mut self, text: impl Into<String>) -> Self {
51 self.hint_text = text.into();
52 self
53 }
54
55 pub const fn retain_previous_results(mut self, retain: bool) -> Self {
59 self.retain_previous_results = retain;
60 self
61 }
62
63 pub const fn wrap_results(mut self, wrap: bool) -> Self {
66 self.wrap_results = wrap;
67 self
68 }
69
70 pub const fn width(mut self, width: f32) -> Self {
72 self.width = Some(width);
73 self
74 }
75
76 pub const fn popup_width(mut self, width: f32) -> Self {
79 self.popup_width = Some(width);
80 self
81 }
82
83 pub fn show<Fut>(
88 self,
89 ui: &mut egui::Ui,
90 fetch: impl FnOnce(String) -> Fut,
91 ) -> (egui::Response, Option<T>)
92 where
93 Fut: Future<Output = Result<Vec<T>, E>> + MaybeSend + 'static,
94 T: MaybeSend + Clone + std::fmt::Display + 'static,
95 E: MaybeSend + 'static,
96 {
97 let AsyncSearch {
98 bind,
99 query,
100 debounce_secs,
101 hint_text,
102 retain_previous_results,
103 wrap_results,
104 width,
105 popup_width,
106 } = self;
107
108 let id = ui.id().with("async_search_state");
109
110 let mut state = ui.data_mut(|d| {
111 d.get_temp::<AsyncSearchState>(id)
112 .unwrap_or_else(|| AsyncSearchState {
113 last_typed: 0.0,
114 last_query: query.clone(),
115 })
116 });
117
118 let mut text_edit = egui::TextEdit::singleline(query).hint_text(&hint_text);
119 if let Some(w) = width {
120 text_edit = text_edit.desired_width(w);
121 }
122
123 let resp = ui.add(text_edit);
124 let curr_time = CURR_FRAME.load(std::sync::atomic::Ordering::Relaxed);
125
126 if resp.changed() {
127 state.last_typed = curr_time;
128 if !retain_previous_results {
129 bind.clear();
130 }
131 }
132
133 let text_trimmed = query.trim().to_string();
134 let is_debouncing = !text_trimmed.is_empty()
135 && curr_time - state.last_typed < debounce_secs
136 && state.last_query != text_trimmed;
137
138 if !text_trimmed.is_empty() && !is_debouncing && state.last_query != text_trimmed {
139 state.last_query.clone_from(&text_trimmed);
140
141 if retain_previous_results {
142 bind.request(fetch(text_trimmed));
143 } else {
144 bind.refresh(fetch(text_trimmed));
145 }
146 } else if state.last_query != text_trimmed {
147 ui.ctx().request_repaint(); }
149
150 ui.data_mut(|d| d.insert_temp(id, state.clone()));
151
152 if query.is_empty() {
154 if !bind.is_idle() {
155 bind.clear();
156 }
157 return (resp, None);
158 }
159
160 let bind_state = bind.get_state();
161 let data = bind.read();
162 let bind_data_is_some = data.is_some();
163
164 let should_show_popup = bind_state != State::Idle
165 || is_debouncing
166 || (retain_previous_results && bind_data_is_some);
167
168 let mut selected_item = None;
169
170 if should_show_popup {
171 let popup_resp = Self::draw_popup(
172 ui,
173 &resp,
174 bind_state,
175 data.as_ref(),
176 query,
177 popup_width,
178 wrap_results,
179 &mut state,
180 &mut selected_item,
181 id,
182 is_debouncing,
183 );
184
185 if Self::handle_click_away(ui, &resp, &popup_resp.response) {
186 bind.clear();
187 }
188 }
189
190 (resp, selected_item)
191 }
192
193 fn handle_click_away(
195 ui: &egui::Ui,
196 input_resp: &egui::Response,
197 popup_resp: &egui::Response,
198 ) -> bool {
199 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
200 return true;
201 }
202
203 if ui.input(|i| i.pointer.any_pressed())
204 && let Some(pos) = ui.input(|i| i.pointer.interact_pos())
205 {
206 let clicked_in_input = input_resp.rect.contains(pos);
207 let clicked_in_popup = popup_resp.rect.contains(pos);
208
209 if !clicked_in_input && !clicked_in_popup {
210 return true;
211 }
212 }
213
214 false
215 }
216
217 #[allow(clippy::too_many_arguments)]
218 fn draw_popup(
219 ui: &egui::Ui,
220 resp: &egui::Response,
221 bind_state: State,
222 data: Option<&Result<Vec<T>, E>>,
223 query: &mut String,
224 popup_width: Option<f32>,
225 wrap_results: bool,
226 state: &mut AsyncSearchState,
227 selected_item: &mut Option<T>,
228 id: egui::Id,
229 is_debouncing: bool,
230 ) -> egui::InnerResponse<()>
231 where
232 T: MaybeSend + Clone + std::fmt::Display + 'static,
233 E: MaybeSend + 'static,
234 {
235 let area = egui::Area::new(id.with("popup_area"))
236 .order(egui::Order::Tooltip)
237 .fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 4.0));
238
239 area.show(ui.ctx(), |ui| {
240 egui::Frame::popup(ui.style()).show(ui, |ui| {
241 let popup_width = popup_width.unwrap_or_else(|| resp.rect.width());
242 ui.set_min_width(popup_width);
243 ui.set_max_width(popup_width);
244
245 if bind_state == State::Pending || is_debouncing {
246 ui.horizontal(|ui| {
247 ui.add_space(4.0);
248 ui.spinner();
249 ui.add_space(4.0);
250 ui.label(if is_debouncing {
251 "Waiting to search..."
252 } else {
253 "Searching..."
254 });
255 });
256 }
257
258 match data {
259 Some(Ok(results)) if results.is_empty() => {
260 if bind_state != State::Pending && !is_debouncing {
261 ui.weak("No results found.");
262 }
263 }
264 Some(Ok(results)) => {
265 if bind_state == State::Pending || is_debouncing {
266 ui.separator();
267 }
268
269 egui::ScrollArea::vertical()
270 .max_height(200.0)
271 .auto_shrink([false, true])
272 .show(ui, |ui| {
273 ui.style_mut().wrap_mode = Some(if wrap_results {
274 egui::TextWrapMode::Wrap
275 } else {
276 egui::TextWrapMode::Truncate
277 });
278
279 ui.with_layout(
280 egui::Layout::top_down_justified(egui::Align::LEFT),
281 |ui| {
282 for item in results {
283 let text = egui::WidgetText::from(item.to_string());
284
285 if ui.selectable_label(false, text).clicked() {
286 *query = item.to_string();
287 state.last_query.clone_from(query);
288 ui.data_mut(|d| d.insert_temp(id, state.clone()));
289 *selected_item = Some(item.clone());
290 }
291 }
292 },
293 );
294 });
295 }
296 Some(Err(_err)) => {
297 if bind_state != State::Pending && !is_debouncing {
298 ui.colored_label(ui.visuals().error_fg_color, "Search failed.");
299 }
300 }
301 None => {}
302 }
303 });
304 })
305 }
306}