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}