Skip to main content

liora_components/
select.rs

1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{
4    App, Bounds, Context, ElementId, Entity, FocusHandle, Focusable, Hsla, MouseButton, Pixels,
5    Render, SharedString, Window, actions, prelude::*,
6};
7use liora_core::{Config, push_portal};
8use liora_icons::Icon;
9use liora_icons_lucide::IconName;
10
11actions!(select, [SelectClose]);
12
13pub struct Select {
14    options: Vec<SharedString>,
15    selected_idx: Option<usize>,
16    is_open: bool,
17    focus_handle: FocusHandle,
18    last_bounds: Option<Bounds<Pixels>>,
19    on_change: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
20    border_none: bool,
21    radius_none: bool,
22    radius_left_none: bool,
23    radius_right_none: bool,
24    width: Option<Pixels>,
25    text_size: Option<Pixels>,
26    text_color: Option<Hsla>,
27    padding_x: Option<Pixels>,
28    close_on_click_outside: bool,
29    close_on_escape: bool,
30}
31
32impl Select {
33    pub fn new(
34        options: Vec<impl Into<SharedString>>,
35        selected_idx: Option<usize>,
36        cx: &mut Context<Self>,
37    ) -> Self {
38        Self {
39            options: options.into_iter().map(|o| o.into()).collect(),
40            selected_idx,
41            is_open: false,
42            focus_handle: cx.focus_handle(),
43            last_bounds: None,
44            on_change: None,
45            border_none: false,
46            radius_none: false,
47            radius_left_none: false,
48            radius_right_none: false,
49            width: None,
50            text_size: None,
51            text_color: None,
52            padding_x: None,
53            close_on_click_outside: true,
54            close_on_escape: true,
55        }
56    }
57
58    pub fn borderless(mut self) -> Self {
59        self.border_none = true;
60        self
61    }
62    pub fn radius_none(mut self) -> Self {
63        self.radius_none = true;
64        self
65    }
66    pub fn radius_left_none(mut self) -> Self {
67        self.radius_left_none = true;
68        self
69    }
70    pub fn radius_right_none(mut self) -> Self {
71        self.radius_right_none = true;
72        self
73    }
74    pub fn width(mut self, w: impl Into<Pixels>) -> Self {
75        self.width = Some(w.into());
76        self
77    }
78
79    pub fn width_xs(self) -> Self {
80        self.width(gpui::px(90.0))
81    }
82
83    pub fn text_size(mut self, s: impl Into<Pixels>) -> Self {
84        self.text_size = Some(s.into());
85        self
86    }
87
88    pub fn text_sm(self) -> Self {
89        self.text_size(gpui::px(14.0))
90    }
91    pub fn text_color(mut self, c: Hsla) -> Self {
92        self.text_color = Some(c);
93        self
94    }
95    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
96        self.padding_x = Some(p.into());
97        self
98    }
99
100    pub fn padding_x_sm(self) -> Self {
101        self.padding_x(gpui::px(8.0))
102    }
103
104    pub fn set_borderless(&mut self, b: bool, cx: &mut Context<Self>) {
105        if self.border_none == b {
106            return;
107        }
108        self.border_none = b;
109        cx.notify();
110    }
111
112    pub fn set_radius_none(&mut self, r: bool, cx: &mut Context<Self>) {
113        if self.radius_none == r {
114            return;
115        }
116        self.radius_none = r;
117        cx.notify();
118    }
119
120    pub fn set_radius_left_none(&mut self, r: bool, cx: &mut Context<Self>) {
121        if self.radius_left_none == r {
122            return;
123        }
124        self.radius_left_none = r;
125        cx.notify();
126    }
127
128    pub fn set_radius_right_none(&mut self, r: bool, cx: &mut Context<Self>) {
129        if self.radius_right_none == r {
130            return;
131        }
132        self.radius_right_none = r;
133        cx.notify();
134    }
135
136    pub fn set_width(&mut self, w: impl Into<Pixels>, cx: &mut Context<Self>) {
137        let w = w.into();
138        if self.width == Some(w) {
139            return;
140        }
141        self.width = Some(w);
142        cx.notify();
143    }
144
145    pub fn set_text_size(&mut self, s: impl Into<Pixels>, cx: &mut Context<Self>) {
146        let s = s.into();
147        if self.text_size == Some(s) {
148            return;
149        }
150        self.text_size = Some(s);
151        cx.notify();
152    }
153
154    pub fn set_text_color(&mut self, c: Hsla, cx: &mut Context<Self>) {
155        if self.text_color == Some(c) {
156            return;
157        }
158        self.text_color = Some(c);
159        cx.notify();
160    }
161
162    pub fn set_padding_x(&mut self, p: impl Into<Pixels>, cx: &mut Context<Self>) {
163        let p = p.into();
164        if self.padding_x == Some(p) {
165            return;
166        }
167        self.padding_x = Some(p);
168        cx.notify();
169    }
170
171    pub fn set_options(&mut self, options: Vec<SharedString>, cx: &mut Context<Self>) {
172        if self.options == options {
173            return;
174        }
175        self.options = options;
176        if let Some(idx) = self.selected_idx
177            && idx >= self.options.len()
178        {
179            self.selected_idx = None;
180        }
181        cx.notify();
182    }
183
184    pub fn set_selected_idx(&mut self, idx: Option<usize>, cx: &mut Context<Self>) {
185        if self.selected_idx == idx {
186            return;
187        }
188        self.selected_idx = idx;
189        cx.notify();
190    }
191
192    pub fn close_on_escape(mut self, close: bool) -> Self {
193        self.close_on_escape = close;
194        self
195    }
196
197    pub fn close_on_click_outside(mut self, close: bool) -> Self {
198        self.close_on_click_outside = close;
199        self
200    }
201
202    pub fn register_key_bindings(cx: &mut App) {
203        cx.bind_keys([gpui::KeyBinding::new("escape", SelectClose, None)]);
204    }
205
206    fn close_on_escape_action(&mut self, _: &SelectClose, _: &mut Window, cx: &mut Context<Self>) {
207        if self.close_on_escape && self.is_open {
208            self.is_open = false;
209            cx.notify();
210        }
211    }
212
213    pub fn on_change(mut self, cb: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
214        self.on_change = Some(Box::new(cb));
215        self
216    }
217
218    pub fn set_on_change(&mut self, cb: impl Fn(usize, &mut Window, &mut App) + 'static) {
219        self.on_change = Some(Box::new(cb));
220    }
221
222    pub fn selected_index(&self) -> Option<usize> {
223        self.selected_idx
224    }
225
226    fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
227        self.is_open = !self.is_open;
228        if self.is_open {
229            window.focus(&self.focus_handle);
230        }
231        cx.notify();
232    }
233
234    fn select_option(&mut self, idx: usize, window: &mut Window, cx: &mut Context<Self>) {
235        self.selected_idx = Some(idx);
236        self.is_open = false;
237        if let Some(ref cb) = self.on_change {
238            cb(idx, window, cx);
239        }
240        cx.notify();
241    }
242}
243
244impl Focusable for Select {
245    fn focus_handle(&self, _cx: &App) -> FocusHandle {
246        self.focus_handle.clone()
247    }
248}
249
250struct BoundsCapturer {
251    select: Entity<Select>,
252}
253
254impl IntoElement for BoundsCapturer {
255    type Element = Self;
256    fn into_element(self) -> Self::Element {
257        self
258    }
259}
260
261impl Element for BoundsCapturer {
262    type RequestLayoutState = ();
263    type PrepaintState = ();
264
265    fn id(&self) -> Option<ElementId> {
266        None
267    }
268    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
269        None
270    }
271
272    fn request_layout(
273        &mut self,
274        _: Option<&gpui::GlobalElementId>,
275        _: Option<&gpui::InspectorElementId>,
276        window: &mut Window,
277        cx: &mut App,
278    ) -> (gpui::LayoutId, ()) {
279        let mut style = gpui::Style::default();
280        style.size.width = gpui::relative(1.0).into();
281        style.size.height = gpui::relative(1.0).into();
282        (window.request_layout(style, [], cx), ())
283    }
284
285    fn prepaint(
286        &mut self,
287        _: Option<&gpui::GlobalElementId>,
288        _: Option<&gpui::InspectorElementId>,
289        bounds: Bounds<Pixels>,
290        _: &mut (),
291        _window: &mut Window,
292        cx: &mut App,
293    ) -> () {
294        self.select.update(cx, |this, _| {
295            this.last_bounds = Some(bounds);
296        });
297    }
298
299    fn paint(
300        &mut self,
301        _: Option<&gpui::GlobalElementId>,
302        _: Option<&gpui::InspectorElementId>,
303        _: Bounds<Pixels>,
304        _: &mut (),
305        _: &mut (),
306        _window: &mut Window,
307        _: &mut App,
308    ) {
309    }
310}
311
312impl Render for Select {
313    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
314        let config = cx.global::<Config>();
315        let theme = config.theme.clone();
316        let focused = self.focus_handle.is_focused(_window);
317
318        let display_text = self
319            .selected_idx
320            .map(|i| self.options[i].clone())
321            .unwrap_or_else(|| "Select...".into());
322
323        let border_color = if focused || self.is_open {
324            theme.primary.base
325        } else {
326            theme.neutral.border
327        };
328        let text_size = self.text_size.unwrap_or(gpui::px(theme.font_size.md));
329        let text_color = self.text_color.unwrap_or(theme.neutral.text_1);
330        let h_px = self.padding_x.unwrap_or(gpui::px(12.0));
331
332        let trigger_content = gpui::div()
333            .flex()
334            .flex_row()
335            .items_center()
336            .justify_between()
337            .w_full()
338            .h(gpui::px(34.0))
339            .px(h_px)
340            .child(
341                gpui::div()
342                    .text_size(text_size)
343                    .text_color(text_color)
344                    .child(display_text),
345            )
346            .child(
347                Icon::new(if self.is_open {
348                    IconName::ChevronUp
349                } else {
350                    IconName::ChevronDown
351                })
352                .size(gpui::px(14.0))
353                .color(theme.neutral.icon),
354            );
355
356        if self.is_open {
357            let options = self.options.clone();
358            let selected_idx = self.selected_idx;
359            let entity = cx.entity().clone();
360            let theme_portal = theme.clone();
361            let trigger_bounds = self.last_bounds;
362
363            push_portal(
364                move |_window, _cx| {
365                    let (top, left, width) = if let Some(b) = trigger_bounds {
366                        (b.bottom() + gpui::px(4.0), b.left(), b.size.width)
367                    } else {
368                        (gpui::px(100.0), gpui::px(100.0), gpui::px(200.0))
369                    };
370
371                    let entity = entity.clone();
372                    let theme = theme_portal.clone();
373
374                    let panel = gpui::div()
375                        .absolute()
376                        .top(top)
377                        .left(left)
378                        .w(width)
379                        .max_h(gpui::px(200.0))
380                        .bg(theme.neutral.card)
381                        .rounded(gpui::px(theme.radius.md))
382                        .border_1()
383                        .border_color(theme.neutral.border)
384                        .shadow(vec![gpui::BoxShadow {
385                            color: theme.neutral.border,
386                            offset: gpui::point(gpui::px(0.0), gpui::px(4.0)),
387                            blur_radius: gpui::px(12.0),
388                            spread_radius: gpui::px(0.0),
389                        }])
390                        .children(options.iter().enumerate().map(|(idx, label)| {
391                            let is_selected = Some(idx) == selected_idx;
392                            let entity = entity.clone();
393                            let theme = theme.clone();
394                            let label = label.clone();
395
396                            gpui::div()
397                                .id(element_id(format!("select-option-{}", idx)))
398                                .px(gpui::px(12.0))
399                                .py(gpui::px(8.0))
400                                .cursor_pointer()
401                                .bg(if is_selected {
402                                    theme.primary.base.opacity(0.1)
403                                } else {
404                                    theme.neutral.card
405                                })
406                                .hover(move |s| {
407                                    s.cursor_pointer().bg(if is_selected {
408                                        theme.neutral.text_3.opacity(0.16)
409                                    } else {
410                                        theme.neutral.hover
411                                    })
412                                })
413                                .child(
414                                    gpui::div()
415                                        .text_size(gpui::px(theme.font_size.md))
416                                        .text_color(if is_selected {
417                                            theme.primary.base
418                                        } else {
419                                            theme.neutral.text_1
420                                        })
421                                        .child(label),
422                                )
423                                .on_mouse_down(MouseButton::Left, move |_, window, cx| {
424                                    entity.update(cx, |this, cx| {
425                                        this.select_option(idx, window, cx);
426                                    });
427                                })
428                        }));
429
430                    pop_in(
431                        element_id(format!("liora-select-panel-motion-{}", entity.entity_id())),
432                        panel,
433                    )
434                    .into_any_element()
435                },
436                cx,
437            );
438        }
439
440        let mut el = gpui::div()
441            .relative()
442            .when_some(self.width, |s, w| s.w(w))
443            .when(self.width.is_none(), |s| s.w_full())
444            .bg(theme.neutral.card)
445            .when(!self.border_none, |s| {
446                s.border_1().border_color(border_color)
447            })
448            .cursor_pointer()
449            .hover(|s| {
450                let s = s.cursor_pointer();
451                if self.border_none {
452                    s
453                } else {
454                    s.border_color(theme.primary.base)
455                }
456            });
457
458        if !self.radius_none {
459            if self.radius_left_none {
460                el = el.rounded_r(gpui::px(theme.radius.md));
461            } else if self.radius_right_none {
462                el = el.rounded_l(gpui::px(theme.radius.md));
463            } else {
464                el = el.rounded(gpui::px(theme.radius.md));
465            }
466        }
467
468        let close_on_click_outside = self.close_on_click_outside;
469
470        el.child(trigger_content)
471            .child(
472                gpui::div()
473                    .absolute()
474                    .top_0()
475                    .left_0()
476                    .size_full()
477                    .child(BoundsCapturer {
478                        select: cx.entity().clone(),
479                    }),
480            )
481            .on_mouse_down(
482                MouseButton::Left,
483                cx.listener(|this, _, window, cx| {
484                    this.toggle_open(window, cx);
485                }),
486            )
487            .when(close_on_click_outside, |s| {
488                s.on_mouse_down_out(cx.listener(|this, _, _, cx| {
489                    this.is_open = false;
490                    cx.notify();
491                }))
492            })
493            .on_action(cx.listener(Self::close_on_escape_action))
494    }
495}