Skip to main content

glassy_ui/
dropdown_menu.rs

1use std::rc::Rc;
2
3use gpui::{
4    anchored, deferred, div, point, prelude::*, px, relative, AnyElement, App, Entity, FocusHandle,
5    FontWeight, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels, Point, RenderOnce,
6    SharedString, StyleRefinement, Styled, Window,
7};
8
9use crate::chrome::box_shadow;
10use crate::compat::{AccessibilityExt, Role, StyleCompatExt};
11
12use crate::icon::{Icon, IconName};
13use crate::kbd::Kbd;
14use crate::motion::{Motion, StyledSlot};
15use crate::theme::{paint, ActiveTheme, Theme, ThemeKind};
16
17type MenuAction = Rc<dyn Fn(&mut Window, &mut App) + 'static>;
18pub(crate) type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App) + 'static>;
19
20#[derive(Clone)]
21pub enum DropdownMenuEntry {
22    Item(DropdownMenuItem),
23    Separator,
24}
25
26impl DropdownMenuEntry {
27    pub fn item(label: impl Into<SharedString>) -> Self {
28        Self::Item(DropdownMenuItem::new(label))
29    }
30
31    pub fn separator() -> Self {
32        Self::Separator
33    }
34}
35
36#[derive(Clone)]
37pub struct DropdownMenuItem {
38    label: SharedString,
39    disabled: bool,
40    destructive: bool,
41    shortcut: Option<SharedString>,
42    submenu: Vec<DropdownMenuEntry>,
43    on_select: Option<MenuAction>,
44}
45
46impl DropdownMenuItem {
47    pub fn new(label: impl Into<SharedString>) -> Self {
48        Self {
49            label: label.into(),
50            disabled: false,
51            destructive: false,
52            shortcut: None,
53            submenu: Vec::new(),
54            on_select: None,
55        }
56    }
57
58    pub fn disabled(mut self, disabled: bool) -> Self {
59        self.disabled = disabled;
60        self
61    }
62
63    pub fn destructive(mut self, destructive: bool) -> Self {
64        self.destructive = destructive;
65        self
66    }
67
68    pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
69        self.shortcut = Some(shortcut.into());
70        self
71    }
72
73    pub fn submenu(mut self, entries: impl IntoIterator<Item = DropdownMenuEntry>) -> Self {
74        self.submenu = entries.into_iter().collect();
75        self
76    }
77
78    pub fn on_select(mut self, listener: impl Fn(&mut Window, &mut App) + 'static) -> Self {
79        self.on_select = Some(Rc::new(listener));
80        self
81    }
82}
83
84impl From<DropdownMenuItem> for DropdownMenuEntry {
85    fn from(item: DropdownMenuItem) -> Self {
86        Self::Item(item)
87    }
88}
89
90pub(crate) struct DropdownMenuState {
91    pub(crate) focus_handle: FocusHandle,
92    pub(crate) open: bool,
93    pub(crate) highlighted: Option<usize>,
94    pub(crate) submenu: Option<SubmenuState>,
95    pub(crate) origin: Point<Pixels>,
96    pub(crate) origin_window: bool,
97    pub(crate) previous_focus: Option<FocusHandle>,
98}
99
100#[derive(Clone, Copy)]
101pub(crate) struct SubmenuState {
102    pub(crate) parent: usize,
103    pub(crate) highlighted: Option<usize>,
104}
105
106pub(crate) fn item_at(entries: &[DropdownMenuEntry], index: usize) -> Option<&DropdownMenuItem> {
107    match entries.get(index) {
108        Some(DropdownMenuEntry::Item(item)) => Some(item),
109        _ => None,
110    }
111}
112
113fn enabled(entries: &[DropdownMenuEntry], index: usize) -> bool {
114    item_at(entries, index).is_some_and(|item| !item.disabled)
115}
116
117pub(crate) fn initial_highlight(entries: &[DropdownMenuEntry]) -> Option<usize> {
118    entries
119        .iter()
120        .enumerate()
121        .find_map(|(index, _)| enabled(entries, index).then_some(index))
122}
123
124pub(crate) fn next_enabled(
125    entries: &[DropdownMenuEntry],
126    current: Option<usize>,
127    forward: bool,
128) -> Option<usize> {
129    if entries.is_empty() {
130        return None;
131    }
132
133    match current {
134        Some(start) => (1..=entries.len())
135            .map(|offset| {
136                if forward {
137                    (start + offset) % entries.len()
138                } else {
139                    (start + entries.len() - (offset % entries.len())) % entries.len()
140                }
141            })
142            .find(|index| enabled(entries, *index)),
143        None if forward => initial_highlight(entries),
144        None => entries
145            .iter()
146            .enumerate()
147            .rev()
148            .find_map(|(index, _)| enabled(entries, index).then_some(index)),
149    }
150}
151
152pub(crate) fn set_open(
153    state: &Entity<DropdownMenuState>,
154    open: bool,
155    entries: &[DropdownMenuEntry],
156    on_open_change: Option<&OpenChangeHandler>,
157    window: &mut Window,
158    cx: &mut App,
159) {
160    if state.read(cx).open == open {
161        return;
162    }
163
164    state.update(cx, |menu, cx| {
165        menu.open = open;
166        menu.highlighted = open.then(|| initial_highlight(entries)).flatten();
167        menu.submenu = None;
168        cx.notify();
169    });
170
171    if let Some(on_open_change) = on_open_change {
172        on_open_change(open, window, cx);
173    }
174    window.refresh();
175}
176
177pub(crate) fn activate_item(
178    item: &DropdownMenuItem,
179    state: &Entity<DropdownMenuState>,
180    entries: &[DropdownMenuEntry],
181    on_open_change: Option<&OpenChangeHandler>,
182    focus_handle: &FocusHandle,
183    window: &mut Window,
184    cx: &mut App,
185) {
186    if item.disabled {
187        return;
188    }
189
190    set_open(state, false, entries, on_open_change, window, cx);
191    focus_handle.focus(window);
192    if let Some(on_select) = &item.on_select {
193        on_select(window, cx);
194    }
195}
196
197pub(crate) fn handle_menu_keydown(
198    event: &KeyDownEvent,
199    state: &Entity<DropdownMenuState>,
200    entries: &[DropdownMenuEntry],
201    on_open_change: Option<&OpenChangeHandler>,
202    focus_handle: &FocusHandle,
203    window: &mut Window,
204    cx: &mut App,
205) {
206    if event.keystroke.modifiers.modified() {
207        return;
208    }
209
210    let key = event.keystroke.key.as_str();
211    let snapshot = state.read(cx);
212    let was_open = snapshot.open;
213    let root_highlighted = snapshot.highlighted;
214    let submenu_state = snapshot.submenu;
215
216    match key {
217        "down" | "up" => {
218            let forward = key == "down";
219            state.update(cx, |menu, cx| {
220                if !menu.open {
221                    menu.open = true;
222                    menu.highlighted = initial_highlight(entries);
223                } else if let Some(submenu) = &mut menu.submenu {
224                    if let Some(parent) = item_at(entries, submenu.parent) {
225                        submenu.highlighted =
226                            next_enabled(&parent.submenu, submenu.highlighted, forward);
227                    }
228                } else {
229                    menu.highlighted = next_enabled(entries, menu.highlighted, forward);
230                }
231                cx.notify();
232            });
233            if !was_open {
234                if let Some(on_open_change) = on_open_change {
235                    on_open_change(true, window, cx);
236                }
237            }
238            cx.stop_propagation();
239        }
240        "enter" | "space" if !was_open => {
241            set_open(state, true, entries, on_open_change, window, cx);
242            cx.stop_propagation();
243        }
244        "right" if was_open && submenu_state.is_none() => {
245            if let Some((parent, item)) = root_highlighted
246                .and_then(|index| item_at(entries, index).map(|item| (index, item)))
247                .filter(|(_, item)| !item.disabled && !item.submenu.is_empty())
248            {
249                state.update(cx, |menu, cx| {
250                    menu.submenu = Some(SubmenuState {
251                        parent,
252                        highlighted: initial_highlight(&item.submenu),
253                    });
254                    cx.notify();
255                });
256                cx.stop_propagation();
257            }
258        }
259        "left" if submenu_state.is_some() => {
260            state.update(cx, |menu, cx| {
261                menu.submenu = None;
262                cx.notify();
263            });
264            cx.stop_propagation();
265        }
266        "enter" | "space" if was_open => {
267            let picked = if let Some(submenu) = submenu_state {
268                item_at(entries, submenu.parent).and_then(|parent| {
269                    submenu
270                        .highlighted
271                        .and_then(|index| item_at(&parent.submenu, index))
272                })
273            } else {
274                root_highlighted.and_then(|index| item_at(entries, index))
275            };
276
277            if let Some(item) = picked.filter(|item| !item.disabled) {
278                if !item.submenu.is_empty() && submenu_state.is_none() {
279                    let parent = root_highlighted.expect("highlighted root item");
280                    state.update(cx, |menu, cx| {
281                        menu.submenu = Some(SubmenuState {
282                            parent,
283                            highlighted: initial_highlight(&item.submenu),
284                        });
285                        cx.notify();
286                    });
287                    window.refresh();
288                } else {
289                    activate_item(
290                        item,
291                        state,
292                        entries,
293                        on_open_change,
294                        focus_handle,
295                        window,
296                        cx,
297                    );
298                }
299            }
300            cx.stop_propagation();
301        }
302        "escape" if was_open => {
303            set_open(state, false, entries, on_open_change, window, cx);
304            focus_handle.focus(window);
305            cx.stop_propagation();
306        }
307        _ => {}
308    }
309}
310
311#[derive(Clone, Copy)]
312struct MenuChrome {
313    background: gpui::Hsla,
314    border: gpui::Hsla,
315    inset: gpui::Hsla,
316    shadow: gpui::Hsla,
317    highlight: gpui::Hsla,
318}
319
320fn menu_chrome(theme: Theme) -> MenuChrome {
321    match theme.kind {
322        ThemeKind::Light => MenuChrome {
323            background: paint(0xFFFFFF85),
324            border: paint(0xFFFFFFB8),
325            inset: paint(0xFFFFFFE6),
326            shadow: paint(0x0F172A0F),
327            highlight: paint(0xFFFFFF47),
328        },
329        ThemeKind::Dark => MenuChrome {
330            background: paint(0xFFFFFF12),
331            border: paint(0xFFFFFF1A),
332            inset: paint(0xFFFFFF1F),
333            shadow: paint(0x00000047),
334            highlight: paint(0xFFFFFF12),
335        },
336    }
337}
338
339#[derive(Clone)]
340pub(crate) struct MenuPanelContext {
341    pub(crate) state: Entity<DropdownMenuState>,
342    pub(crate) root_entries: Vec<DropdownMenuEntry>,
343    pub(crate) focus_handle: FocusHandle,
344    pub(crate) on_open_change: Option<OpenChangeHandler>,
345}
346
347pub(crate) fn render_panel(
348    id: SharedString,
349    entries: Vec<DropdownMenuEntry>,
350    highlighted: Option<usize>,
351    submenu_parent: Option<usize>,
352    context: MenuPanelContext,
353    outside_dismiss: bool,
354    cx: &mut App,
355) -> AnyElement {
356    let theme = cx.theme();
357    let chrome = menu_chrome(theme);
358    let panel_selector = format!("{id}-panel");
359    let surface_id = format!("{id}-surface");
360    let dismiss_state = context.state.clone();
361    let dismiss_entries = context.root_entries.clone();
362    let dismiss_change = context.on_open_change.clone();
363
364    let panel = div()
365        .id(SharedString::from(panel_selector.clone()))
366        .debug_selector(move || panel_selector.clone())
367        .role(Role::Menu)
368        .flex()
369        .flex_col()
370        .w(px(240.))
371        .flex_shrink_0()
372        .p(px(4.))
373        .gap(px(2.))
374        .rounded(px(6.))
375        .border_1()
376        .border_color(chrome.border)
377        .bg(chrome.background)
378        .shadow(vec![
379            box_shadow(0., 1., chrome.inset, 0., 0.),
380            box_shadow(0., 6., chrome.shadow, 16., 0.),
381        ])
382        .occlude()
383        .when(outside_dismiss, |panel| {
384            panel.on_mouse_down_out(move |_, window, cx| {
385                set_open(
386                    &dismiss_state,
387                    false,
388                    &dismiss_entries,
389                    dismiss_change.as_ref(),
390                    window,
391                    cx,
392                );
393            })
394        })
395        .children(entries.into_iter().enumerate().map(|(index, entry)| {
396            let DropdownMenuEntry::Item(item) = entry else {
397                return div()
398                    .id(SharedString::from(format!("{id}-separator-{index}")))
399                    .debug_selector({
400                        let selector = format!("{id}-separator-{index}");
401                        move || selector.clone()
402                    })
403                    .w(px(232.))
404                    .h(px(1.))
405                    .flex_shrink_0()
406                    .bg(if theme.is_dark() {
407                        paint(0xFAFAFA1F)
408                    } else {
409                        paint(0x18181B1F)
410                    })
411                    .into_any_element();
412            };
413
414            let row_selector = format!("{id}-item-{index}");
415            let is_highlighted = highlighted == Some(index);
416            let is_submenu_open = submenu_parent == Some(index);
417            let has_submenu = !item.submenu.is_empty();
418            let interactive = !item.disabled;
419            let text_color = if item.disabled {
420                theme.label
421            } else if item.destructive {
422                theme.destructive
423            } else {
424                theme.ink
425            };
426            let click_item = item.clone();
427            let click_state = context.state.clone();
428            let click_entries = context.root_entries.clone();
429            let click_focus = context.focus_handle.clone();
430            let click_change = context.on_open_change.clone();
431            let submenu_entries = item.submenu.clone();
432
433            let mut row = div()
434                .id(SharedString::from(row_selector.clone()))
435                .debug_selector(move || row_selector.clone())
436                .role(Role::MenuItem)
437                .aria_selected(is_highlighted)
438                .when(has_submenu, |row| row.aria_expanded(is_submenu_open))
439                .relative()
440                .flex()
441                .items_center()
442                .justify_between()
443                .h(px(32.))
444                .flex_shrink_0()
445                .px(px(10.))
446                .rounded(px(4.))
447                .when(is_highlighted, |row| row.bg(chrome.highlight))
448                .when(interactive, |row| {
449                    row.cursor_pointer()
450                        .hover(move |style| style.bg(chrome.highlight))
451                })
452                .when(!interactive, |row| row.cursor_default())
453                .child(
454                    div()
455                        .min_w(px(0.))
456                        .font_family(theme.font_family)
457                        .font_weight(FontWeight::NORMAL)
458                        .text_size(px(14.))
459                        .line_height(px(18.))
460                        .text_color(text_color)
461                        .child(item.label.clone()),
462                )
463                .when_some(item.shortcut.clone(), |row, shortcut| {
464                    row.child(Kbd::new(shortcut))
465                })
466                .when(has_submenu, |row| {
467                    row.child(
468                        Icon::new(IconName::ChevronRight)
469                            .px(px(16.))
470                            .color(theme.label),
471                    )
472                });
473
474            if interactive {
475                row = row.on_click(move |_, window, cx| {
476                    if has_submenu {
477                        click_state.update(cx, |menu, cx| {
478                            menu.highlighted = Some(index);
479                            menu.submenu = Some(SubmenuState {
480                                parent: index,
481                                highlighted: initial_highlight(&submenu_entries),
482                            });
483                            cx.notify();
484                        });
485                        window.refresh();
486                    } else {
487                        activate_item(
488                            &click_item,
489                            &click_state,
490                            &click_entries,
491                            click_change.as_ref(),
492                            &click_focus,
493                            window,
494                            cx,
495                        );
496                    }
497                });
498            }
499
500            if is_submenu_open {
501                let submenu = item.submenu.clone();
502                let submenu_highlighted = context
503                    .state
504                    .read(cx)
505                    .submenu
506                    .and_then(|menu| menu.highlighted);
507                let submenu_panel = render_panel(
508                    SharedString::from(format!("{id}-submenu-{index}")),
509                    submenu,
510                    submenu_highlighted,
511                    None,
512                    context.clone(),
513                    true,
514                    cx,
515                );
516                row = row.child(
517                    div().absolute().left(relative(1.)).top(px(-4.)).child(
518                        anchored()
519                            .offset(point(px(6.), px(0.)))
520                            .snap_to_window_with_margin(px(8.))
521                            .child(submenu_panel),
522                    ),
523                );
524            }
525
526            row.into_any_element()
527        }));
528
529    Motion::new()
530        .id(surface_id)
531        .surface_in()
532        .child(panel)
533        .into_any_element()
534}
535
536/// Trigger-anchored menu with pointer, keyboard, focus, and one-level submenu behavior.
537#[derive(IntoElement)]
538pub struct DropdownMenu {
539    id: SharedString,
540    controlled_open: Option<bool>,
541    default_open: bool,
542    trigger_label: SharedString,
543    trigger: Option<AnyElement>,
544    entries: Vec<DropdownMenuEntry>,
545    on_open_change: Option<OpenChangeHandler>,
546    style: StyleRefinement,
547}
548
549impl DropdownMenu {
550    pub fn new(id: impl Into<SharedString>) -> Self {
551        Self {
552            id: id.into(),
553            controlled_open: None,
554            default_open: false,
555            trigger_label: SharedString::from("Open menu"),
556            trigger: None,
557            entries: Vec::new(),
558            on_open_change: None,
559            style: StyleRefinement::default(),
560        }
561    }
562
563    pub fn open(mut self, open: bool) -> Self {
564        self.controlled_open = Some(open);
565        self
566    }
567
568    pub fn default_open(mut self, open: bool) -> Self {
569        self.default_open = open;
570        self
571    }
572
573    pub fn trigger_label(mut self, label: impl Into<SharedString>) -> Self {
574        self.trigger_label = label.into();
575        self
576    }
577
578    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
579        self.trigger = Some(trigger.into_any_element());
580        self
581    }
582
583    pub fn entries(mut self, entries: impl IntoIterator<Item = DropdownMenuEntry>) -> Self {
584        self.entries = entries.into_iter().collect();
585        self
586    }
587
588    pub fn on_open_change(
589        mut self,
590        listener: impl Fn(bool, &mut Window, &mut App) + 'static,
591    ) -> Self {
592        self.on_open_change = Some(Rc::new(listener));
593        self
594    }
595}
596
597impl Styled for DropdownMenu {
598    fn style(&mut self) -> &mut StyleRefinement {
599        &mut self.style
600    }
601}
602
603impl RenderOnce for DropdownMenu {
604    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
605        let has_trigger = self.trigger.is_some();
606        let initial_open = self.controlled_open.unwrap_or(self.default_open);
607        let initial_entries = self.entries.clone();
608        let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| DropdownMenuState {
609            focus_handle: cx.focus_handle().tab_stop(true),
610            open: initial_open,
611            highlighted: initial_open
612                .then(|| initial_highlight(&initial_entries))
613                .flatten(),
614            submenu: None,
615            origin: point(px(0.), px(0.)),
616            origin_window: false,
617            previous_focus: None,
618        });
619
620        if let Some(controlled_open) = self.controlled_open {
621            if state.read(cx).open != controlled_open {
622                state.update(cx, |menu, _| {
623                    menu.open = controlled_open;
624                    menu.highlighted = controlled_open
625                        .then(|| initial_highlight(&self.entries))
626                        .flatten();
627                    menu.submenu = None;
628                });
629            }
630        }
631
632        let open = state.read(cx).open;
633        let highlighted = state.read(cx).highlighted;
634        let submenu = state.read(cx).submenu;
635        let focus_handle = state.read(cx).focus_handle.clone();
636        let trigger_selector = format!("{}-trigger", self.id);
637        let trigger_state = state.clone();
638        let trigger_entries = self.entries.clone();
639        let trigger_change = self.on_open_change.clone();
640        let trigger_focus = focus_handle.clone();
641        let keyboard_state = state.clone();
642        let keyboard_entries = self.entries.clone();
643        let keyboard_change = self.on_open_change.clone();
644        let keyboard_focus = focus_handle.clone();
645
646        let trigger = div()
647            .id(SharedString::from(trigger_selector.clone()))
648            .debug_selector(move || trigger_selector.clone())
649            .role(Role::Button)
650            .aria_label(self.trigger_label)
651            .aria_expanded(open)
652            .track_focus(&focus_handle)
653            .tab_stop(true)
654            .relative()
655            .flex()
656            .items_center()
657            .cursor_pointer()
658            .on_mouse_down(MouseButton::Left, move |_, window, cx| {
659                trigger_focus.focus(window);
660                let next_open = !trigger_state.read(cx).open;
661                set_open(
662                    &trigger_state,
663                    next_open,
664                    &trigger_entries,
665                    trigger_change.as_ref(),
666                    window,
667                    cx,
668                );
669            })
670            .on_key_down(move |event: &KeyDownEvent, window, cx| {
671                handle_menu_keydown(
672                    event,
673                    &keyboard_state,
674                    &keyboard_entries,
675                    keyboard_change.as_ref(),
676                    &keyboard_focus,
677                    window,
678                    cx,
679                );
680            })
681            .when_some(self.trigger, |trigger, content| trigger.child(content));
682
683        let panel_context = MenuPanelContext {
684            state,
685            root_entries: self.entries.clone(),
686            focus_handle,
687            on_open_change: self.on_open_change,
688        };
689        let panel = render_panel(
690            self.id.clone(),
691            self.entries.clone(),
692            highlighted,
693            submenu.map(|submenu| submenu.parent),
694            panel_context,
695            submenu.is_none(),
696            cx,
697        );
698        let content = if has_trigger {
699            div()
700                .absolute()
701                .left(px(0.))
702                .top(relative(1.))
703                .child(
704                    deferred(
705                        anchored()
706                            .offset(point(px(0.), px(6.)))
707                            .snap_to_window_with_margin(px(8.))
708                            .child(panel),
709                    )
710                    .with_priority(2),
711                )
712                .into_any_element()
713        } else {
714            panel
715        };
716
717        div()
718            .relative()
719            .flex()
720            .flex_none()
721            .self_start()
722            .items_center()
723            .refine_style(&self.style)
724            .when(has_trigger, |menu| menu.child(trigger))
725            .when(open, |menu| menu.child(content))
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn materials_match_paper() {
735        let light = menu_chrome(Theme::light());
736        assert_eq!(light.background, paint(0xFFFFFF85));
737        assert_eq!(light.border, paint(0xFFFFFFB8));
738        assert_eq!(light.highlight, paint(0xFFFFFF47));
739
740        let dark = menu_chrome(Theme::dark());
741        assert_eq!(dark.background, paint(0xFFFFFF12));
742        assert_eq!(dark.border, paint(0xFFFFFF1A));
743        assert_eq!(dark.highlight, paint(0xFFFFFF12));
744    }
745
746    #[test]
747    fn navigation_skips_separators_and_disabled_items() {
748        let entries = vec![
749            DropdownMenuEntry::separator(),
750            DropdownMenuItem::new("Disabled").disabled(true).into(),
751            DropdownMenuItem::new("Ready").into(),
752        ];
753        assert_eq!(initial_highlight(&entries), Some(2));
754        assert_eq!(next_enabled(&entries, Some(2), true), Some(2));
755    }
756}