Skip to main content

liora_components/
autocomplete.rs

1use crate::Input;
2use gpui::{
3    App, Bounds, Context, Element, ElementId, Entity, FocusHandle, Focusable, GlobalElementId,
4    InspectorElementId, IntoElement, LayoutId, MouseButton, Pixels, Render, SharedString, Style,
5    Window, actions, prelude::*, px, relative,
6};
7use liora_core::{Config, push_portal};
8use liora_icons_lucide::IconName;
9
10actions!(autocomplete, [AutocompleteClose]);
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct AutocompleteItem {
14    pub value: SharedString,
15    pub label: SharedString,
16}
17
18impl AutocompleteItem {
19    pub fn new(value: impl Into<SharedString>) -> Self {
20        let value = value.into();
21        Self {
22            label: value.clone(),
23            value,
24        }
25    }
26
27    pub fn labeled(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
28        Self {
29            value: value.into(),
30            label: label.into(),
31        }
32    }
33}
34
35pub struct Autocomplete {
36    input: Entity<Input>,
37    items: Vec<AutocompleteItem>,
38    is_open: bool,
39    disabled: bool,
40    clearable: bool,
41    suffix_icon: Option<IconName>,
42    placeholder: SharedString,
43    width: Option<Pixels>,
44    max_suggestions: usize,
45    trigger_on_focus: bool,
46    last_bounds: Option<Bounds<Pixels>>,
47    focus_handle: FocusHandle,
48    on_select: Option<Box<dyn Fn(AutocompleteItem, &mut Window, &mut App) + 'static>>,
49    close_on_click_outside: bool,
50    close_on_escape: bool,
51}
52
53impl Autocomplete {
54    pub fn new(items: Vec<AutocompleteItem>, cx: &mut Context<Self>) -> Self {
55        Self {
56            input: cx.new(|cx| {
57                Input::new("", cx)
58                    .clearable(true)
59                    .icon_suffix(IconName::Search)
60            }),
61            items,
62            is_open: false,
63            disabled: false,
64            clearable: true,
65            suffix_icon: Some(IconName::Search),
66            placeholder: "Type to search".into(),
67            width: Some(px(280.0)),
68            max_suggestions: 8,
69            trigger_on_focus: true,
70            last_bounds: None,
71            focus_handle: cx.focus_handle(),
72            on_select: None,
73            close_on_click_outside: true,
74            close_on_escape: true,
75        }
76    }
77
78    pub fn from_values(values: Vec<impl Into<SharedString>>, cx: &mut Context<Self>) -> Self {
79        Self::new(values.into_iter().map(AutocompleteItem::new).collect(), cx)
80    }
81
82    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
83        self.placeholder = placeholder.into();
84        self
85    }
86
87    pub fn disabled(mut self, disabled: bool) -> Self {
88        self.disabled = disabled;
89        self
90    }
91
92    pub fn clearable(mut self, clearable: bool) -> Self {
93        self.clearable = clearable;
94        self
95    }
96
97    pub fn suffix_icon(mut self, icon: IconName) -> Self {
98        self.suffix_icon = Some(icon);
99        self
100    }
101
102    pub fn no_suffix_icon(mut self) -> Self {
103        self.suffix_icon = None;
104        self
105    }
106
107    pub fn suffix_icon_value(&self) -> Option<IconName> {
108        self.suffix_icon
109    }
110
111    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
112        self.width = Some(width.into());
113        self
114    }
115
116    pub fn width_lg(self) -> Self {
117        self.width(px(320.0))
118    }
119
120    pub fn max_suggestions(mut self, max: usize) -> Self {
121        self.max_suggestions = max.max(1);
122        self
123    }
124
125    pub fn trigger_on_focus(mut self, trigger: bool) -> Self {
126        self.trigger_on_focus = trigger;
127        self
128    }
129
130    pub fn close_on_escape(mut self, close: bool) -> Self {
131        self.close_on_escape = close;
132        self
133    }
134
135    pub fn close_on_click_outside(mut self, close: bool) -> Self {
136        self.close_on_click_outside = close;
137        self
138    }
139
140    pub fn register_key_bindings(cx: &mut App) {
141        cx.bind_keys([gpui::KeyBinding::new("escape", AutocompleteClose, None)]);
142    }
143
144    fn close_on_escape_action(
145        &mut self,
146        _: &AutocompleteClose,
147        _: &mut Window,
148        cx: &mut Context<Self>,
149    ) {
150        if self.close_on_escape && self.is_open {
151            self.is_open = false;
152            cx.notify();
153        }
154    }
155
156    pub fn on_select(
157        mut self,
158        cb: impl Fn(AutocompleteItem, &mut Window, &mut App) + 'static,
159    ) -> Self {
160        self.on_select = Some(Box::new(cb));
161        self
162    }
163
164    pub fn value(&self, cx: &App) -> SharedString {
165        self.input.read(cx).value()
166    }
167
168    pub fn set_items(&mut self, items: Vec<AutocompleteItem>, cx: &mut Context<Self>) {
169        if self.items == items {
170            return;
171        }
172        self.items = items;
173        cx.notify();
174    }
175
176    pub fn matching_items_for(
177        items: &[AutocompleteItem],
178        query: &str,
179        max: usize,
180    ) -> Vec<AutocompleteItem> {
181        let query = query.trim().to_lowercase();
182        items
183            .iter()
184            .filter(|item| {
185                query.is_empty()
186                    || item.value.to_string().to_lowercase().contains(&query)
187                    || item.label.to_string().to_lowercase().contains(&query)
188            })
189            .take(max.max(1))
190            .cloned()
191            .collect()
192    }
193
194    fn matching_items(&self, cx: &App) -> Vec<AutocompleteItem> {
195        Self::matching_items_for(
196            &self.items,
197            self.input.read(cx).value().as_ref(),
198            self.max_suggestions,
199        )
200    }
201
202    fn select_item(&mut self, item: AutocompleteItem, window: &mut Window, cx: &mut Context<Self>) {
203        self.input.update(cx, |input, cx| {
204            input.set_value(item.value.clone(), cx);
205        });
206        self.is_open = false;
207        if let Some(ref cb) = self.on_select {
208            cb(item, window, cx);
209        }
210        cx.notify();
211    }
212}
213
214impl Focusable for Autocomplete {
215    fn focus_handle(&self, _cx: &App) -> FocusHandle {
216        self.focus_handle.clone()
217    }
218}
219
220struct BoundsCapturer {
221    autocomplete: Entity<Autocomplete>,
222}
223
224impl IntoElement for BoundsCapturer {
225    type Element = Self;
226    fn into_element(self) -> Self::Element {
227        self
228    }
229}
230
231impl Element for BoundsCapturer {
232    type RequestLayoutState = ();
233    type PrepaintState = ();
234
235    fn id(&self) -> Option<ElementId> {
236        None
237    }
238
239    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
240        None
241    }
242
243    fn request_layout(
244        &mut self,
245        _: Option<&GlobalElementId>,
246        _: Option<&InspectorElementId>,
247        window: &mut Window,
248        cx: &mut App,
249    ) -> (LayoutId, ()) {
250        let mut style = Style::default();
251        style.size.width = relative(1.0).into();
252        style.size.height = relative(1.0).into();
253        (window.request_layout(style, [], cx), ())
254    }
255
256    fn prepaint(
257        &mut self,
258        _: Option<&GlobalElementId>,
259        _: Option<&InspectorElementId>,
260        bounds: Bounds<Pixels>,
261        _: &mut (),
262        _window: &mut Window,
263        cx: &mut App,
264    ) {
265        self.autocomplete.update(cx, |this, _| {
266            this.last_bounds = Some(bounds);
267        });
268    }
269
270    fn paint(
271        &mut self,
272        _: Option<&GlobalElementId>,
273        _: Option<&InspectorElementId>,
274        _: Bounds<Pixels>,
275        _: &mut (),
276        _: &mut (),
277        _window: &mut Window,
278        _: &mut App,
279    ) {
280    }
281}
282
283impl Render for Autocomplete {
284    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
285        let theme = cx.global::<Config>().theme.clone();
286        let entity = cx.entity().clone();
287        let disabled = self.disabled;
288        let placeholder = self.placeholder.clone();
289        let clearable = self.clearable;
290        let suffix_icon = self.suffix_icon;
291
292        self.input.update(cx, |input, _| {
293            input.set_on_change({
294                let entity = entity.clone();
295                move |value, cx| {
296                    entity.update(cx, |this, cx| {
297                        this.is_open = !value.is_empty();
298                        cx.notify();
299                    });
300                }
301            });
302        });
303        self.input.update(cx, |input, cx| {
304            input.set_placeholder(placeholder, cx);
305            input.set_disabled(disabled, cx);
306            input.set_clearable(clearable && !disabled, cx);
307            input.set_icon_suffix(suffix_icon, cx);
308        });
309
310        let matches = self.matching_items(cx);
311        if self.is_open && !disabled {
312            let trigger_bounds = self.last_bounds;
313            let entity = cx.entity().clone();
314            let theme_portal = theme.clone();
315            let close_on_click_outside = self.close_on_click_outside;
316            push_portal(
317                move |_window, _cx| {
318                    let (top, left, width) = trigger_bounds
319                        .map(|b| (b.bottom() + px(4.0), b.left(), b.size.width))
320                        .unwrap_or((px(120.0), px(120.0), px(280.0)));
321                    let entity = entity.clone();
322                    let theme = theme_portal.clone();
323                    let mut panel = gpui::div()
324                        .absolute()
325                        .top(top)
326                        .left(left)
327                        .w(width)
328                        .max_h(px(240.0))
329                        .bg(theme.neutral.card)
330                        .rounded(px(theme.radius.md))
331                        .border_1()
332                        .border_color(theme.neutral.border)
333                        .shadow_lg();
334                    panel = panel.when(close_on_click_outside, |panel| {
335                        panel.on_mouse_down_out({
336                            let entity = entity.clone();
337                            move |_, _, cx| {
338                                entity.update(cx, |this, cx| {
339                                    this.is_open = false;
340                                    cx.notify();
341                                });
342                            }
343                        })
344                    });
345
346                    if matches.is_empty() {
347                        panel = panel.child(
348                            gpui::div()
349                                .px(px(12.0))
350                                .py(px(10.0))
351                                .text_size(px(theme.font_size.sm))
352                                .text_color(theme.neutral.text_3)
353                                .child("No matching suggestions"),
354                        );
355                    } else {
356                        panel = panel.children(matches.iter().map(|item| {
357                            let item = item.clone();
358                            let entity = entity.clone();
359                            let theme = theme.clone();
360                            gpui::div()
361                                .flex()
362                                .items_center()
363                                .justify_between()
364                                .gap_3()
365                                .px(px(12.0))
366                                .py(px(8.0))
367                                .cursor_pointer()
368                                .hover(|s| s.cursor_pointer().bg(theme.neutral.hover))
369                                .child(
370                                    gpui::div()
371                                        .text_size(px(theme.font_size.md))
372                                        .text_color(theme.neutral.text_1)
373                                        .child(item.label.clone()),
374                                )
375                                .child(
376                                    gpui::div()
377                                        .text_xs()
378                                        .text_color(theme.neutral.text_3)
379                                        .child(item.value.clone()),
380                                )
381                                .on_mouse_down(MouseButton::Left, move |_, window, cx| {
382                                    let item = item.clone();
383                                    entity.update(cx, |this, cx| {
384                                        this.select_item(item, window, cx);
385                                    });
386                                    cx.stop_propagation();
387                                })
388                        }));
389                    }
390                    panel.into_any_element()
391                },
392                cx,
393            );
394        }
395
396        let frame = gpui::div()
397            .relative()
398            .when_some(self.width, |s, width| s.w(width))
399            .when(self.width.is_none(), |s| s.w_full())
400            .child(
401                gpui::div()
402                    .absolute()
403                    .top_0()
404                    .left_0()
405                    .size_full()
406                    .child(BoundsCapturer {
407                        autocomplete: cx.entity().clone(),
408                    }),
409            )
410            .child(self.input.clone());
411
412        frame.on_action(cx.listener(Self::close_on_escape_action))
413    }
414}