Skip to main content

egui_async/egui/widgets/
async_search.rs

1//! A text input widget that automatically triggers async fetches as the user types.
2
3use crate::bind::{Bind, CURR_FRAME, MaybeSend, State};
4
5#[derive(Clone)]
6struct AsyncSearchState {
7    last_typed: f64,
8    last_query: String,
9}
10
11/// A text edit widget that debounces input and automatically triggers an asynchronous search.
12///
13/// `AsyncSearch` listens to changes in a query string and waits for a specified debounce
14/// threshold before launching the background task. It shows a spinner while the search
15/// is in flight and displays results in a floating dropdown portal.
16#[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    /// Creates a new `AsyncSearch` bound to the provided query string and results bind.
30    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    /// Sets the debounce timer threshold (in seconds) before making an async search call.
44    pub const fn debounce_secs(mut self, secs: f64) -> Self {
45        self.debounce_secs = secs;
46        self
47    }
48
49    /// Sets the placeholder text for the search box.
50    pub fn hint_text(mut self, text: impl Into<String>) -> Self {
51        self.hint_text = text.into();
52        self
53    }
54
55    /// If set to `true` (default), the widget will display the results of the previous
56    /// successful search while the user is typing the next query and while the next
57    /// query is pending.
58    pub const fn retain_previous_results(mut self, retain: bool) -> Self {
59        self.retain_previous_results = retain;
60        self
61    }
62
63    /// Determines if the returned text inside the result rows should wrap or truncate.
64    /// Default is `false` (truncate).
65    pub const fn wrap_results(mut self, wrap: bool) -> Self {
66        self.wrap_results = wrap;
67        self
68    }
69
70    /// Explicitly forces the width of the input box.
71    pub const fn width(mut self, width: f32) -> Self {
72        self.width = Some(width);
73        self
74    }
75
76    /// Sets a fixed width for the search results popup.
77    /// If not provided, it will automatically match the width of the text input.
78    pub const fn popup_width(mut self, width: f32) -> Self {
79        self.popup_width = Some(width);
80        self
81    }
82
83    /// Renders the search box and processes background fetch logic.
84    ///
85    /// Returns the text edit response, and an `Option<T>` containing the selected
86    /// item if the user just clicked one.
87    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(); // Stay responsive during debounce window
148        }
149
150        ui.data_mut(|d| d.insert_temp(id, state.clone()));
151
152        // Early return for empty queries removes deep nesting.
153        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    /// Evaluates if the user clicked away or hit escape to dismiss the widget.
194    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}