Skip to main content

glassy_ui/
select.rs

1use std::rc::Rc;
2
3use crate::motion::{Motion, StyledSlot};
4use crate::theme::{rgb, ActiveTheme, Theme, ThemeKind};
5use gpui::{
6    anchored, deferred, div, point, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight,
7    IntoElement, KeyDownEvent, RenderOnce, SharedString, StyleRefinement, Styled, Window,
8};
9
10use crate::compat::{AccessibilityExt, Role};
11
12use crate::button::ButtonVariant;
13use crate::chrome::{box_shadow, button_chrome, field_chrome, FieldState};
14use crate::icon::{Icon, IconName};
15
16type SelectClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
17type SelectPickHandler = Rc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
18type SelectOpenHandler = Rc<dyn Fn(bool, &mut Window, &mut App) + 'static>;
19
20struct SelectState {
21    focus_handle: FocusHandle,
22    open: bool,
23    value: Option<SharedString>,
24    last_value_prop: Option<SharedString>,
25    highlighted: Option<usize>,
26}
27
28fn initial_highlight(items: &[SelectItem], value: Option<&SharedString>) -> Option<usize> {
29    value
30        .and_then(|value| {
31            items
32                .iter()
33                .position(|item| !item.disabled && item.value.as_ref() == value.as_ref())
34        })
35        .or_else(|| items.iter().position(|item| !item.disabled))
36}
37
38fn next_enabled(items: &[SelectItem], current: Option<usize>, forward: bool) -> Option<usize> {
39    if items.is_empty() {
40        return None;
41    }
42
43    match current {
44        Some(start) => (1..=items.len())
45            .map(|offset| {
46                if forward {
47                    (start + offset) % items.len()
48                } else {
49                    (start + items.len() - (offset % items.len())) % items.len()
50                }
51            })
52            .find(|index| !items[*index].disabled),
53        None if forward => items.iter().position(|item| !item.disabled),
54        None => items.iter().rposition(|item| !item.disabled),
55    }
56}
57
58fn select_menu_bg(theme: Theme) -> gpui::Hsla {
59    match theme.kind {
60        ThemeKind::Light => rgb(0xF4F5F7),
61        ThemeKind::Dark => rgb(0x1C1D20),
62    }
63}
64
65/// One row in the open list.
66#[derive(Clone, Debug)]
67pub struct SelectItem {
68    pub value: SharedString,
69    pub disabled: bool,
70}
71
72impl SelectItem {
73    pub fn new(value: impl Into<SharedString>) -> Self {
74        Self {
75            value: value.into(),
76            disabled: false,
77        }
78    }
79
80    pub fn disabled(mut self, disabled: bool) -> Self {
81        self.disabled = disabled;
82        self
83    }
84}
85
86/// Closed field is Input chrome plus a chevron. Open list is secondary glass.
87#[derive(IntoElement)]
88pub struct Select {
89    id: SharedString,
90    placeholder: SharedString,
91    value: Option<SharedString>,
92    items: Vec<SelectItem>,
93    disabled: bool,
94    focused: bool,
95    controlled_open: Option<bool>,
96    default_open: bool,
97    style: StyleRefinement,
98    on_click: Option<SelectClickHandler>,
99    on_select: Option<SelectPickHandler>,
100    on_open_change: Option<SelectOpenHandler>,
101}
102
103impl Select {
104    pub fn new(id: impl Into<SharedString>) -> Self {
105        Self {
106            id: id.into(),
107            placeholder: SharedString::from("Choose format"),
108            value: None,
109            items: Vec::new(),
110            disabled: false,
111            focused: false,
112            controlled_open: None,
113            default_open: false,
114            style: StyleRefinement::default(),
115            on_click: None,
116            on_select: None,
117            on_open_change: None,
118        }
119    }
120
121    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
122        self.placeholder = placeholder.into();
123        self
124    }
125
126    pub fn value(mut self, value: impl Into<SharedString>) -> Self {
127        self.value = Some(value.into());
128        self
129    }
130
131    pub fn items(mut self, items: impl IntoIterator<Item = SelectItem>) -> Self {
132        self.items = items.into_iter().collect();
133        self
134    }
135
136    pub fn disabled(mut self, disabled: bool) -> Self {
137        self.disabled = disabled;
138        self
139    }
140
141    pub fn focused(mut self, focused: bool) -> Self {
142        self.focused = focused;
143        self
144    }
145
146    /// Keep the list open or closed from parent state.
147    pub fn open(mut self, open: bool) -> Self {
148        self.controlled_open = Some(open);
149        self
150    }
151
152    pub fn default_open(mut self, open: bool) -> Self {
153        self.default_open = open;
154        self
155    }
156
157    pub fn on_click(
158        mut self,
159        listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
160    ) -> Self {
161        self.on_click = Some(Rc::new(listener));
162        self
163    }
164
165    pub fn on_select(
166        mut self,
167        listener: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
168    ) -> Self {
169        self.on_select = Some(Rc::new(listener));
170        self
171    }
172
173    pub fn on_open_change(
174        mut self,
175        listener: impl Fn(bool, &mut Window, &mut App) + 'static,
176    ) -> Self {
177        self.on_open_change = Some(Rc::new(listener));
178        self
179    }
180}
181
182impl Styled for Select {
183    fn style(&mut self) -> &mut StyleRefinement {
184        &mut self.style
185    }
186}
187
188fn set_open(
189    state: &gpui::Entity<SelectState>,
190    open: bool,
191    items: &[SelectItem],
192    on_open_change: Option<&SelectOpenHandler>,
193    window: &mut Window,
194    cx: &mut App,
195) {
196    if state.read(cx).open == open {
197        return;
198    }
199    state.update(cx, |select, cx| {
200        select.open = open;
201        select.highlighted = open
202            .then(|| initial_highlight(items, select.value.as_ref()))
203            .flatten();
204        if !open {
205            select.highlighted = None;
206        }
207        cx.notify();
208    });
209    if let Some(on_open_change) = on_open_change {
210        on_open_change(open, window, cx);
211    }
212}
213
214impl RenderOnce for Select {
215    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
216        let initial_open = self.controlled_open.unwrap_or(self.default_open);
217        let initial_value = self.value.clone();
218        let initial_items = self.items.clone();
219        let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| SelectState {
220            focus_handle: cx.focus_handle().tab_stop(true),
221            open: initial_open,
222            highlighted: initial_open
223                .then(|| initial_highlight(&initial_items, initial_value.as_ref()))
224                .flatten(),
225            value: initial_value.clone(),
226            last_value_prop: initial_value,
227        });
228
229        if let Some(controlled_open) = self.controlled_open {
230            if state.read(cx).open != controlled_open {
231                state.update(cx, |select, _| {
232                    select.open = controlled_open;
233                    select.highlighted = controlled_open
234                        .then(|| initial_highlight(&self.items, select.value.as_ref()))
235                        .flatten();
236                });
237            }
238        }
239        if state.read(cx).last_value_prop != self.value {
240            state.update(cx, |select, _| {
241                select.value = self.value.clone();
242                select.last_value_prop = self.value.clone();
243            });
244        }
245
246        let open = state.read(cx).open;
247        let value = state.read(cx).value.clone();
248        let highlighted = state.read(cx).highlighted;
249        let focus_handle = state.read(cx).focus_handle.clone();
250        let theme = cx.theme();
251        let field_state = if self.disabled {
252            FieldState::Disabled
253        } else if self.focused || open || focus_handle.is_focused(window) {
254            FieldState::Focus
255        } else {
256            FieldState::Rest
257        };
258        let chrome = field_chrome(theme, field_state);
259        let list_chrome = button_chrome(theme, ButtonVariant::Secondary);
260        let list_bg = select_menu_bg(theme);
261        let ghost = button_chrome(theme, ButtonVariant::Ghost);
262
263        let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
264        if chrome.shadow_blur > 0.0 {
265            shadows.push(box_shadow(
266                0.,
267                chrome.shadow_y,
268                chrome.shadow,
269                chrome.shadow_blur,
270                0.,
271            ));
272        }
273        if let Some(ring) = chrome.ring {
274            shadows.push(box_shadow(0., 0., ring, 0., 3.));
275        }
276
277        let has_value = value.is_some();
278        let label = value.clone().unwrap_or_else(|| self.placeholder.clone());
279        let label_color = if self.disabled || !has_value {
280            chrome.placeholder
281        } else {
282            chrome.fg
283        };
284
285        let interactive = !self.disabled;
286        let on_click = self.on_click.clone();
287        let on_open_change = self.on_open_change.clone();
288        let trigger_state = state.clone();
289        let trigger_items = self.items.clone();
290        let trigger_focus = focus_handle.clone();
291        let trigger_open_change = on_open_change.clone();
292        let next_open = !open;
293        let trigger_debug_selector = format!("{}-trigger", self.id);
294        let mut trigger = div()
295            .id(self.id.clone())
296            .debug_selector(move || trigger_debug_selector)
297            .role(Role::ComboBox)
298            .aria_expanded(open)
299            .flex()
300            .items_center()
301            .gap(px(8.))
302            .w(px(280.))
303            .h(px(36.))
304            .flex_shrink_0()
305            .px(px(14.))
306            .rounded(px(6.))
307            .border_1()
308            .border_color(chrome.border)
309            .bg(chrome.bg)
310            .shadow(shadows)
311            .when(interactive, |el| el.cursor_pointer())
312            .when(!interactive, |el| el.cursor_default())
313            .child(
314                div()
315                    .flex_1()
316                    .min_w(px(0.))
317                    .font_family(theme.font_family)
318                    .font_weight(FontWeight::NORMAL)
319                    .text_size(px(14.))
320                    .line_height(px(18.))
321                    .text_color(label_color)
322                    .child(label),
323            )
324            .child(
325                Icon::new(IconName::ChevronDown)
326                    .px(px(16.))
327                    .color(theme.label),
328            );
329
330        if interactive {
331            trigger = trigger.on_click(move |event, window, cx| {
332                trigger_focus.focus(window);
333                set_open(
334                    &trigger_state,
335                    next_open,
336                    &trigger_items,
337                    trigger_open_change.as_ref(),
338                    window,
339                    cx,
340                );
341                if let Some(on_click) = &on_click {
342                    on_click(event, window, cx);
343                }
344            });
345        }
346
347        let mut list_shadows = vec![box_shadow(0., 1., list_chrome.inset, 0., 0.)];
348        if list_chrome.shadow_blur > 0.0 {
349            list_shadows.push(box_shadow(
350                0.,
351                list_chrome.shadow_y,
352                list_chrome.shadow,
353                list_chrome.shadow_blur,
354                0.,
355            ));
356        }
357
358        let selected = value.clone();
359        let on_select = self.on_select.clone();
360        let keyboard_items = self.items.clone();
361        let keyboard_on_select = self.on_select.clone();
362        let ghost_bg = ghost.bg;
363        let ghost_hover = ghost.hover_bg;
364        let select_id = self.id.clone();
365        let popup_debug_selector = format!("{select_id}-popup");
366        let dismiss_state = state.clone();
367        let dismiss_items = self.items.clone();
368        let dismiss_open_change = on_open_change.clone();
369        let list = div()
370            .id(SharedString::from(format!("{select_id}-popup")))
371            .debug_selector(move || popup_debug_selector)
372            .flex()
373            .flex_col()
374            .w(px(280.))
375            .flex_shrink_0()
376            .p(px(4.))
377            .rounded(px(6.))
378            .border_1()
379            .border_color(list_chrome.border)
380            .bg(list_bg)
381            .shadow(list_shadows)
382            .occlude()
383            .on_mouse_down_out(move |_, window, cx| {
384                set_open(
385                    &dismiss_state,
386                    false,
387                    &dismiss_items,
388                    dismiss_open_change.as_ref(),
389                    window,
390                    cx,
391                );
392            })
393            .children(self.items.into_iter().enumerate().map(|(index, item)| {
394                let is_selected = selected
395                    .as_ref()
396                    .is_some_and(|value| value.as_ref() == item.value.as_ref());
397                let is_highlighted = highlighted == Some(index);
398                let enabled = !item.disabled;
399                let text_color = if item.disabled {
400                    theme.label
401                } else {
402                    theme.ink
403                };
404                let item_value = item.value.clone();
405                let on_select = on_select.clone();
406                let row_open_change = on_open_change.clone();
407                let row_state = state.clone();
408                let row_id = SharedString::from(format!("{select_id}-{index}"));
409                let selected_motion_id = format!("{select_id}-{index}-selected");
410
411                let mut row = div()
412                    .id(row_id)
413                    .flex()
414                    .items_center()
415                    .gap(px(8.))
416                    .h(px(32.))
417                    .flex_shrink_0()
418                    .px(px(8.))
419                    .rounded(px(6.))
420                    .when(is_selected || is_highlighted, |el| el.bg(ghost_bg))
421                    .when(enabled, |el| {
422                        el.cursor_pointer().hover(move |s| s.bg(ghost_hover))
423                    })
424                    .when(!enabled, |el| el.cursor_default())
425                    .child(div().size(px(16.)).flex_shrink_0().when(is_selected, |el| {
426                        el.child(
427                            Motion::new()
428                                .id(selected_motion_id)
429                                .selection_in()
430                                .child(Icon::new(IconName::Check).px(px(16.)).color(theme.ink)),
431                        )
432                    }))
433                    .child(
434                        div()
435                            .font_family(theme.font_family)
436                            .font_weight(FontWeight::NORMAL)
437                            .text_size(px(14.))
438                            .line_height(px(18.))
439                            .text_color(text_color)
440                            .child(item.value.clone()),
441                    );
442
443                if enabled {
444                    row = row.on_click(move |_, window, cx| {
445                        row_state.update(cx, |select, cx| {
446                            select.value = Some(item_value.clone());
447                            cx.notify();
448                        });
449                        set_open(&row_state, false, &[], row_open_change.as_ref(), window, cx);
450                        if let Some(on_select) = &on_select {
451                            on_select(&item_value, window, cx);
452                        }
453                    });
454                }
455
456                row
457            }));
458
459        let list = Motion::new()
460            .id(format!("{select_id}-surface"))
461            .surface_in()
462            .child(list);
463        let popup = deferred(anchored().offset(point(px(0.), px(8.))).child(list)).with_priority(1);
464
465        let keyboard_state = state.clone();
466        let keyboard_open_change = on_open_change.clone();
467
468        div()
469            .relative()
470            .w(px(280.))
471            .h(px(36.))
472            .flex_shrink_0()
473            .refine_style(&self.style)
474            .track_focus(&focus_handle)
475            .tab_stop(interactive)
476            .on_key_down(move |event: &KeyDownEvent, window, cx| {
477                if !interactive || event.keystroke.modifiers.modified() {
478                    return;
479                }
480
481                let key = event.keystroke.key.as_str();
482                let (was_open, current) = {
483                    let snapshot = keyboard_state.read(cx);
484                    (snapshot.open, snapshot.highlighted)
485                };
486
487                match key {
488                    "down" | "up" => {
489                        let next = next_enabled(&keyboard_items, current, key == "down");
490                        if !was_open {
491                            set_open(
492                                &keyboard_state,
493                                true,
494                                &keyboard_items,
495                                keyboard_open_change.as_ref(),
496                                window,
497                                cx,
498                            );
499                        }
500                        keyboard_state.update(cx, |select, cx| {
501                            select.highlighted = next;
502                            cx.notify();
503                        });
504                        cx.stop_propagation();
505                    }
506                    "enter" | "space" if !was_open => {
507                        set_open(
508                            &keyboard_state,
509                            true,
510                            &keyboard_items,
511                            keyboard_open_change.as_ref(),
512                            window,
513                            cx,
514                        );
515                        cx.stop_propagation();
516                    }
517                    "enter" | "space" => {
518                        let picked = current
519                            .and_then(|index| keyboard_items.get(index))
520                            .filter(|item| !item.disabled)
521                            .map(|item| item.value.clone());
522                        if let Some(picked) = picked {
523                            keyboard_state.update(cx, |select, cx| {
524                                select.value = Some(picked.clone());
525                                cx.notify();
526                            });
527                            set_open(
528                                &keyboard_state,
529                                false,
530                                &keyboard_items,
531                                keyboard_open_change.as_ref(),
532                                window,
533                                cx,
534                            );
535                            if let Some(on_select) = &keyboard_on_select {
536                                on_select(&picked, window, cx);
537                            }
538                        }
539                        cx.stop_propagation();
540                    }
541                    "escape" if was_open => {
542                        set_open(
543                            &keyboard_state,
544                            false,
545                            &keyboard_items,
546                            keyboard_open_change.as_ref(),
547                            window,
548                            cx,
549                        );
550                        cx.stop_propagation();
551                    }
552                    _ => {}
553                }
554            })
555            .child(trigger)
556            .when(open && !self.disabled, |el| el.child(popup))
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn menu_backdrop_is_opaque_and_matches_the_secondary_composite() {
566        let light = select_menu_bg(Theme::light());
567        let dark = select_menu_bg(Theme::dark());
568
569        assert_eq!(light, rgb(0xF4F5F7));
570        assert_eq!(dark, rgb(0x1C1D20));
571        assert_eq!(light.a, 1.0);
572        assert_eq!(dark.a, 1.0);
573    }
574}