Skip to main content

gpui_kit/overlay/
menu.rs

1//! Lists of commands: one opened from a trigger, one opened at the pointer.
2//!
3//! A menu presents what can be done and reports what was chosen. It never
4//! does any of it, which is why a checkable item reports the intent to change
5//! and keeps drawing the state the host still holds.
6//!
7//! [`Menu`] and [`ContextMenu`] differ only in what opens them, so the item
8//! model, the cursor, and the panels are shared: [`MenuState`] holds where the
9//! keyboard is and which submenus are open, and both views render it through
10//! the same panels.
11
12use std::rc::Rc;
13
14use gpui::{
15    AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
16    IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
17    SharedString, StatefulInteractiveElement, Styled, Transformation, Window, div,
18    prelude::FluentBuilder, px,
19};
20use gpui_kit_assets::{Icon, icon};
21use gpui_kit_semantics::{NodeSpec, Role, Semantic};
22use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Space, Theme};
23
24use crate::controls::button::{Button, ButtonJoin, ButtonVariant};
25use crate::display::icon::flips;
26use crate::foundation::direction::ActiveDirection;
27use crate::foundation::{Ident, Pressable, Sizable, StyledExt};
28use crate::motion;
29use crate::overlay::focus::FocusTrap;
30use crate::overlay::kbd::Kbd;
31use crate::overlay::layer::{Overlay, Placement, surface};
32use crate::overlay::popover::{self, MenuKey};
33
34/// The narrowest a panel gets, so a one-word command still reads as a menu.
35const PANEL_MIN_WIDTH: f32 = 200.0;
36/// The leading slot every row reserves, so labels line up whether or not the
37/// row carries a check or an icon.
38const GLYPH_SLOT: f32 = 16.0;
39
40/// What one entry in a menu is.
41#[derive(Debug, Clone, PartialEq, Eq)]
42enum MenuItemKind {
43    Command,
44    /// Carries a checked state the host owns.
45    Check(bool),
46    Separator,
47    /// A caption naming the group that follows.
48    Section,
49    Submenu(Vec<MenuItem>),
50}
51
52/// One entry in a menu, identified by business identity rather than position.
53///
54/// An id is unique within its menu, including across submenus, because it is
55/// what the menu reports and what a test addresses.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct MenuItem {
58    id: SharedString,
59    label: SharedString,
60    kind: MenuItemKind,
61    shortcut: Option<SharedString>,
62    icon: Option<Icon>,
63    disabled: bool,
64}
65
66impl MenuItem {
67    /// Something to do, reported as [`MenuEvent::Invoked`] when taken.
68    pub fn command(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
69        Self {
70            id: id.into(),
71            label: label.into(),
72            kind: MenuItemKind::Command,
73            shortcut: None,
74            icon: None,
75            disabled: false,
76        }
77    }
78
79    /// Something that is on or off. The menu draws `checked` and reports the
80    /// intent to change it; it never changes it itself.
81    pub fn check(
82        id: impl Into<SharedString>,
83        label: impl Into<SharedString>,
84        checked: bool,
85    ) -> Self {
86        Self {
87            kind: MenuItemKind::Check(checked),
88            ..Self::command(id, label)
89        }
90    }
91
92    /// A rule between groups. It carries an id because it is published, and
93    /// the keyboard steps over it.
94    pub fn separator(id: impl Into<SharedString>) -> Self {
95        Self {
96            kind: MenuItemKind::Separator,
97            ..Self::command(id, "")
98        }
99    }
100
101    /// A caption naming the group that follows. Not something to act on.
102    pub fn section(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
103        Self {
104            kind: MenuItemKind::Section,
105            ..Self::command(id, label)
106        }
107    }
108
109    /// A nested menu, opening to the side.
110    pub fn submenu(
111        id: impl Into<SharedString>,
112        label: impl Into<SharedString>,
113        items: impl IntoIterator<Item = MenuItem>,
114    ) -> Self {
115        Self {
116            kind: MenuItemKind::Submenu(items.into_iter().collect()),
117            ..Self::command(id, label)
118        }
119    }
120
121    /// The keystroke that does the same thing without the menu.
122    pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
123        self.shortcut = Some(keystroke.into());
124        self
125    }
126
127    pub fn icon(mut self, glyph: Icon) -> Self {
128        self.icon = Some(glyph);
129        self
130    }
131
132    /// Refuses the item. A refused item installs no handler at all.
133    pub fn disabled(mut self, disabled: bool) -> Self {
134        self.disabled = disabled;
135        self
136    }
137
138    pub fn id(&self) -> &SharedString {
139        &self.id
140    }
141
142    pub fn label(&self) -> &SharedString {
143        &self.label
144    }
145
146    pub fn is_disabled(&self) -> bool {
147        self.disabled
148    }
149
150    /// Whether the keyboard can land on this row.
151    fn is_selectable(&self) -> bool {
152        !self.disabled
153            && matches!(
154                self.kind,
155                MenuItemKind::Command | MenuItemKind::Check(_) | MenuItemKind::Submenu(_)
156            )
157    }
158
159    fn children(&self) -> Option<&[MenuItem]> {
160        match &self.kind {
161            MenuItemKind::Submenu(children) => Some(children),
162            _ => None,
163        }
164    }
165}
166
167/// What activating a row did.
168#[derive(Debug, Clone, PartialEq, Eq)]
169enum Activation {
170    Invoked(SharedString),
171    OpenedSubmenu,
172    /// A separator, a section label, or a refused row: nothing happened.
173    Ignored,
174}
175
176/// Where the keyboard is, and which submenus stand open.
177///
178/// Held separately from the items so both menu views share one set of
179/// movement rules, and so those rules can be tested without a window.
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181struct MenuState {
182    /// The index path of the open submenus, outermost first.
183    path: Vec<usize>,
184    /// The row the keyboard is on in the deepest open panel, which is not a
185    /// choice until it is taken.
186    active: Option<usize>,
187}
188
189/// The items of the panel `path` addresses.
190fn level<'a>(items: &'a [MenuItem], path: &[usize]) -> &'a [MenuItem] {
191    let mut level = items;
192    for index in path {
193        match level.get(*index).and_then(MenuItem::children) {
194            Some(children) => level = children,
195            None => break,
196        }
197    }
198    level
199}
200
201fn item_at<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a MenuItem> {
202    let (last, parents) = path.split_last()?;
203    level(items, parents).get(*last)
204}
205
206fn first_selectable(items: &[MenuItem]) -> Option<usize> {
207    items.iter().position(MenuItem::is_selectable)
208}
209
210impl MenuState {
211    fn current<'a>(&self, items: &'a [MenuItem]) -> &'a [MenuItem] {
212        level(items, &self.path)
213    }
214
215    fn reset(&mut self) {
216        self.path.clear();
217        self.active = None;
218    }
219
220    /// Moves the cursor, stepping over separators, section labels, and rows
221    /// the host has refused.
222    fn step(&mut self, items: &[MenuItem], delta: isize) {
223        let level = self.current(items);
224        let count = level.len();
225        let Some(start) = popover::step(self.active, count, delta) else {
226            return;
227        };
228        let mut index = start;
229        for _ in 0..count {
230            if level[index].is_selectable() {
231                self.active = Some(index);
232                return;
233            }
234            index = (index as isize + delta.signum()).rem_euclid(count as isize) as usize;
235        }
236    }
237
238    /// Jumps to the next row whose label starts with `letter`.
239    fn jump(&mut self, items: &[MenuItem], letter: char) -> bool {
240        let labels: Vec<Option<&str>> = self
241            .current(items)
242            .iter()
243            .map(|item| item.is_selectable().then(|| item.label.as_ref()))
244            .collect();
245        match popover::jump_to(&labels, self.active, letter) {
246            Some(index) => {
247                self.active = Some(index);
248                true
249            }
250            None => false,
251        }
252    }
253
254    /// Opens the submenu the cursor is on and moves into it.
255    fn enter(&mut self, items: &[MenuItem]) -> bool {
256        let Some(active) = self.active else {
257            return false;
258        };
259        let Some(item) = self.current(items).get(active) else {
260            return false;
261        };
262        if !item.is_selectable() || item.children().is_none() {
263            return false;
264        }
265        self.path.push(active);
266        self.active = first_selectable(self.current(items));
267        true
268    }
269
270    /// Closes the deepest submenu and puts the cursor back on the row that
271    /// opened it. Reports whether there was one to close.
272    fn leave(&mut self) -> bool {
273        match self.path.pop() {
274            Some(index) => {
275                self.active = Some(index);
276                true
277            }
278            None => false,
279        }
280    }
281
282    fn activate(&mut self, items: &[MenuItem], path: &[usize]) -> Activation {
283        let Some((last, parents)) = path.split_last() else {
284            return Activation::Ignored;
285        };
286        let Some(item) = level(items, parents).get(*last) else {
287            return Activation::Ignored;
288        };
289        if !item.is_selectable() {
290            return Activation::Ignored;
291        }
292        // Acting in a shallower panel closes whatever stood open past it.
293        self.path = parents.to_vec();
294        self.active = Some(*last);
295        if item.children().is_some() {
296            self.path = path.to_vec();
297            self.active = first_selectable(self.current(items));
298            return Activation::OpenedSubmenu;
299        }
300        Activation::Invoked(item.id.clone())
301    }
302
303    /// The index path of the item carrying `id`, at any depth.
304    fn path_to(items: &[MenuItem], id: &str) -> Option<Vec<usize>> {
305        for (index, item) in items.iter().enumerate() {
306            if item.id == id {
307                return Some(vec![index]);
308            }
309            if let Some(children) = item.children()
310                && let Some(mut rest) = Self::path_to(children, id)
311            {
312                rest.insert(0, index);
313                return Some(rest);
314            }
315        }
316        None
317    }
318}
319
320/// Reports the row a pointer or the keyboard took.
321type Activate<V> = Rc<dyn Fn(&mut V, Vec<usize>, &mut Window, &mut Context<V>)>;
322
323/// Renders the open panels side by side, outermost first.
324fn panels<V: 'static>(
325    ident: &Ident,
326    items: &[MenuItem],
327    state: &MenuState,
328    root_parent: SharedString,
329    theme: &Theme,
330    cx: &mut Context<V>,
331    activate: Activate<V>,
332) -> gpui::Div {
333    let depth = state.path.len();
334    let mut rendered: Vec<AnyElement> = Vec::with_capacity(depth + 1);
335    for level_depth in 0..=depth {
336        let base = state.path[..level_depth].to_vec();
337        let parent = if level_depth == 0 {
338            root_parent.clone()
339        } else {
340            match item_at(items, &base) {
341                Some(item) => ident.child(item.id.as_ref()).semantic_id(),
342                None => root_parent.clone(),
343            }
344        };
345        let active = if level_depth == depth {
346            state.active
347        } else {
348            None
349        };
350        let opened = (level_depth < depth).then(|| state.path[level_depth]);
351        rendered.push(panel(
352            ident,
353            level(items, &base),
354            &base,
355            parent,
356            active,
357            opened,
358            theme,
359            cx,
360            activate.clone(),
361        ));
362    }
363
364    div()
365        .flex()
366        .flex_row()
367        .items_start()
368        .gap(px(theme.space(Space::Xs)))
369        .children(rendered)
370}
371
372#[allow(clippy::too_many_arguments)]
373fn panel<V: 'static>(
374    ident: &Ident,
375    items: &[MenuItem],
376    base: &[usize],
377    parent: SharedString,
378    active: Option<usize>,
379    opened: Option<usize>,
380    theme: &Theme,
381    cx: &mut Context<V>,
382    activate: Activate<V>,
383) -> AnyElement {
384    let rows = items
385        .iter()
386        .enumerate()
387        .map(|(index, item)| {
388            let mut path = base.to_vec();
389            path.push(index);
390            row(
391                ident,
392                item,
393                path,
394                parent.clone(),
395                active == Some(index),
396                opened == Some(index),
397                index,
398                items.len(),
399                theme,
400                cx,
401                activate.clone(),
402            )
403        })
404        .collect::<Vec<_>>();
405
406    surface(theme, Elevation::Overlay)
407        .min_w(px(PANEL_MIN_WIDTH))
408        .p_token(theme, Space::Xs)
409        .children(rows)
410        .into_any_element()
411}
412
413#[allow(clippy::too_many_arguments)]
414fn row<V: 'static>(
415    ident: &Ident,
416    item: &MenuItem,
417    path: Vec<usize>,
418    parent: SharedString,
419    active: bool,
420    opened: bool,
421    index: usize,
422    count: usize,
423    theme: &Theme,
424    cx: &mut Context<V>,
425    activate: Activate<V>,
426) -> AnyElement {
427    let row_ident = ident.child(item.id.as_ref());
428    match &item.kind {
429        MenuItemKind::Separator => popover::separator(theme)
430            .semantic_in(
431                cx,
432                NodeSpec::new(row_ident.semantic_id(), Role::Separator).parent(parent),
433            )
434            .into_any_element(),
435        MenuItemKind::Section => popover::heading(theme, item.label.as_ref())
436            .semantic_in(
437                cx,
438                NodeSpec::new(row_ident.semantic_id(), Role::Heading)
439                    .parent(parent)
440                    .level(2)
441                    .text(item.label.clone()),
442            )
443            .into_any_element(),
444        kind => {
445            let checked = match kind {
446                MenuItemKind::Check(checked) => Some(*checked),
447                _ => None,
448            };
449            let submenu = item.children().is_some();
450            let mut spec = NodeSpec::new(row_ident.semantic_id(), Role::MenuItem)
451                .parent(parent)
452                .text(item.label.clone())
453                .disabled(item.disabled)
454                .hovered(active);
455            if let Some(checked) = checked {
456                spec = spec.checked(checked);
457            }
458            if submenu {
459                spec = spec.expanded(opened);
460            }
461
462            let glyph = match (checked, item.icon) {
463                (Some(true), _) => Some(Icon::Check),
464                (Some(false), _) => None,
465                (None, glyph) => glyph,
466            };
467
468            let row =
469                popover::menu_row(theme, false, active || opened)
470                    .id(row_ident.element_id())
471                    .when(active, |element| element.aria_active_descendant())
472                    .when(!item.disabled, |element| {
473                        element.cursor_pointer().pressable(cx)
474                    })
475                    .when(item.disabled, |element| {
476                        element.opacity(theme.opacity.disabled)
477                    })
478                    .child(
479                        div()
480                            .flex()
481                            .flex_none()
482                            .w(px(GLYPH_SLOT))
483                            .justify_center()
484                            .children(glyph.map(|glyph| {
485                                icon(glyph).size(px(14.0)).text_color(theme.colors.text)
486                            })),
487                    )
488                    .child(div().flex_1().child(item.label.clone()))
489                    .children(
490                        item.shortcut
491                            .clone()
492                            .map(|keystroke| Kbd::new(keystroke).into_any_element()),
493                    )
494                    .when(submenu, |element| {
495                        // The chevron says "there is more this way", and this
496                        // way is the way the menu reads.
497                        let flipped = flips(Icon::AltArrowRight, cx.layout_direction());
498                        element.child(
499                            icon(Icon::AltArrowRight)
500                                .size(px(12.0))
501                                .text_color(theme.colors.text_muted)
502                                .when(flipped, |glyph| {
503                                    glyph.with_transformation(Transformation::scale(gpui::size(
504                                        -1.0, 1.0,
505                                    )))
506                                }),
507                        )
508                    })
509                    .when(!item.disabled, |element| {
510                        element.on_click(cx.listener(move |view, _, window, cx| {
511                            activate(view, path.clone(), window, cx);
512                        }))
513                    })
514                    .semantic_in(cx, spec);
515
516            motion::row_in(row_ident.child("in").element_id(), theme, index, count, row)
517                .into_any_element()
518        }
519    }
520}
521
522/// What a menu reports. The owner decides what any of it means.
523#[derive(Debug, Clone, PartialEq, Eq)]
524pub enum MenuEvent {
525    Opened,
526    /// A command or a checkable row was taken. For a checkable row this is the
527    /// intent to change it, which the menu does not act on itself.
528    Invoked(SharedString),
529    /// The menu was waved away, by escape or by a click outside it.
530    Dismissed,
531    Closed,
532}
533
534impl EventEmitter<MenuEvent> for Menu {}
535
536/// A list of commands, opened from a trigger.
537pub struct Menu {
538    ident: Ident,
539    focus_handle: FocusHandle,
540    trigger_focus: FocusHandle,
541    trigger: SharedString,
542    trigger_icon: Option<Icon>,
543    /// What the trigger is called when it carries a glyph instead of a label.
544    trigger_name: Option<SharedString>,
545    trigger_variant: ButtonVariant,
546    trigger_join: ButtonJoin,
547    trigger_size: ControlSize,
548    items: Vec<MenuItem>,
549    placement: Placement,
550    open: bool,
551    /// Set when opening, cleared by the first frame that can act on it.
552    pending_focus: bool,
553    state: MenuState,
554    trap: FocusTrap,
555}
556
557impl std::fmt::Debug for Menu {
558    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        formatter
560            .debug_struct("Menu")
561            .field("ident", &self.ident)
562            .field("trigger", &self.trigger)
563            .field("items", &self.items.len())
564            .field("open", &self.open)
565            .field("open_submenus", &self.state.path.len())
566            .finish()
567    }
568}
569
570impl Menu {
571    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
572        Self {
573            ident: ident.into(),
574            focus_handle: cx.focus_handle(),
575            trigger_focus: cx.focus_handle(),
576            trigger: SharedString::default(),
577            trigger_icon: None,
578            trigger_name: None,
579            trigger_variant: ButtonVariant::Secondary,
580            trigger_join: ButtonJoin::Alone,
581            trigger_size: ControlSize::Md,
582            items: Vec::new(),
583            placement: Placement::Below,
584            open: false,
585            pending_focus: false,
586            state: MenuState::default(),
587            trap: FocusTrap::new(),
588        }
589    }
590
591    /// The label of the control that opens the menu.
592    pub fn trigger(mut self, label: impl Into<SharedString>) -> Self {
593        self.trigger = label.into();
594        self
595    }
596
597    pub fn trigger_icon(mut self, glyph: Icon) -> Self {
598        self.trigger_icon = Some(glyph);
599        self
600    }
601
602    /// What the trigger is called when it has no label of its own, for a menu
603    /// opened from a glyph such as the arrow of a split button.
604    pub fn trigger_name(mut self, name: impl Into<SharedString>) -> Self {
605        self.trigger_name = Some(name.into());
606        self
607    }
608
609    pub fn set_trigger_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
610        self.trigger_name = Some(name.into());
611        cx.notify();
612    }
613
614    /// How the trigger is painted, for a menu that sits inside another
615    /// control and has to look like part of it.
616    pub fn trigger_variant(mut self, variant: ButtonVariant) -> Self {
617        self.trigger_variant = variant;
618        self
619    }
620
621    /// Where the trigger sits in a joined run of buttons.
622    pub fn trigger_join(mut self, join: ButtonJoin) -> Self {
623        self.trigger_join = join;
624        self
625    }
626
627    /// Repaints the trigger for an owner whose own paint is decided after the
628    /// menu is built.
629    pub fn set_trigger_style(
630        &mut self,
631        variant: ButtonVariant,
632        size: ControlSize,
633        cx: &mut Context<Self>,
634    ) {
635        self.trigger_variant = variant;
636        self.trigger_size = size;
637        cx.notify();
638    }
639
640    pub fn items(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
641        self.items = items.into_iter().collect();
642        self
643    }
644
645    pub fn placement(mut self, placement: Placement) -> Self {
646        self.placement = placement;
647        self
648    }
649
650    /// The rows the menu currently offers, so an owner that maintains them can
651    /// tell whether a replacement would change anything.
652    pub fn offered(&self) -> &[MenuItem] {
653        &self.items
654    }
655
656    /// Replaces the items from the host side, dropping a cursor that pointed
657    /// into what is no longer offered.
658    pub fn set_items(&mut self, items: Vec<MenuItem>, cx: &mut Context<Self>) {
659        self.items = items;
660        self.state.reset();
661        cx.notify();
662    }
663
664    pub fn is_open(&self) -> bool {
665        self.open
666    }
667
668    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
669        if self.open {
670            return;
671        }
672        self.open = true;
673        self.pending_focus = true;
674        self.state.reset();
675        self.state.active = first_selectable(&self.items);
676        self.trap.engage(window, cx);
677        cx.emit(MenuEvent::Opened);
678        cx.notify();
679    }
680
681    /// Opens the menu with the submenu carrying `id` already expanded, and
682    /// reports whether such a submenu exists.
683    pub fn open_submenu(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) -> bool {
684        let Some(path) = MenuState::path_to(&self.items, id) else {
685            return false;
686        };
687        if item_at(&self.items, &path)
688            .and_then(MenuItem::children)
689            .is_none()
690        {
691            return false;
692        }
693        self.open(window, cx);
694        self.state.path = path;
695        self.state.active = first_selectable(self.state.current(&self.items));
696        cx.notify();
697        true
698    }
699
700    /// Closes the menu and every submenu under it, and gives the keyboard back
701    /// to the trigger.
702    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
703        if !self.open {
704            return;
705        }
706        self.open = false;
707        self.pending_focus = false;
708        self.state.reset();
709        self.trap.release(window, cx);
710        self.trigger_focus.focus(window, cx);
711        cx.emit(MenuEvent::Closed);
712        cx.notify();
713    }
714
715    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
716        if !self.open {
717            return;
718        }
719        cx.emit(MenuEvent::Dismissed);
720        self.close(window, cx);
721    }
722
723    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
724        if self.open {
725            self.dismiss(window, cx);
726        } else {
727            self.open(window, cx);
728        }
729    }
730
731    fn take(&mut self, path: Vec<usize>, window: &mut Window, cx: &mut Context<Self>) {
732        match self.state.activate(&self.items, &path) {
733            Activation::Invoked(id) => {
734                cx.emit(MenuEvent::Invoked(id));
735                self.close(window, cx);
736            }
737            Activation::OpenedSubmenu => cx.notify(),
738            Activation::Ignored => {}
739        }
740    }
741
742    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
743        if !self.open {
744            return;
745        }
746        let handled = handle_key(&mut self.state, &self.items, event);
747        match handled {
748            Handled::Moved => {
749                cx.notify();
750                cx.stop_propagation();
751            }
752            Handled::Activate(path) => {
753                self.take(path, window, cx);
754                cx.stop_propagation();
755            }
756            Handled::Close => {
757                self.dismiss(window, cx);
758                cx.stop_propagation();
759            }
760            Handled::None => {}
761        }
762    }
763}
764
765/// What a keystroke asked a menu to do.
766enum Handled {
767    Moved,
768    Activate(Vec<usize>),
769    /// Close the whole menu: nothing was left to fold away.
770    Close,
771    None,
772}
773
774/// Applies one keystroke to the cursor, shared by both menu views.
775fn handle_key(state: &mut MenuState, items: &[MenuItem], event: &KeyDownEvent) -> Handled {
776    let key = popover::classify_key(
777        event.keystroke.key.as_str(),
778        event.keystroke.modifiers.platform,
779        event.keystroke.modifiers.control,
780    );
781    match key {
782        MenuKey::Down => {
783            state.step(items, 1);
784            Handled::Moved
785        }
786        MenuKey::Up => {
787            state.step(items, -1);
788            Handled::Moved
789        }
790        // A sideways key that entered or left a submenu is this menu's; one
791        // that found no submenu to move through did nothing, and saying so is
792        // what lets a container the menu sits in — a menubar — take the same
793        // key and step to the next menu instead.
794        MenuKey::Right => {
795            if state.enter(items) {
796                Handled::Moved
797            } else {
798                Handled::None
799            }
800        }
801        MenuKey::Left => {
802            if state.leave() {
803                Handled::Moved
804            } else {
805                Handled::None
806            }
807        }
808        MenuKey::Enter => match state.active {
809            Some(active) => {
810                let mut path = state.path.clone();
811                path.push(active);
812                Handled::Activate(path)
813            }
814            None => Handled::Moved,
815        },
816        // Escape folds one submenu away at a time, so a typist who opened one
817        // by mistake does not lose the whole menu.
818        MenuKey::Escape => {
819            if state.leave() {
820                Handled::Moved
821            } else {
822                Handled::Close
823            }
824        }
825        _ => match popover::typed_letter(event.keystroke.key.as_str(), event.keystroke.modifiers) {
826            Some(letter) if state.jump(items, letter) => Handled::Moved,
827            _ => Handled::None,
828        },
829    }
830}
831
832impl Sizable for Menu {
833    fn control_size(mut self, size: ControlSize) -> Self {
834        self.trigger_size = size;
835        self
836    }
837}
838
839impl Focusable for Menu {
840    fn focus_handle(&self, _cx: &App) -> FocusHandle {
841        self.focus_handle.clone()
842    }
843}
844
845impl Render for Menu {
846    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
847        let theme = cx.theme().clone();
848        let menu = cx.entity().downgrade();
849        let glyph_only = self.trigger.is_empty();
850        let trigger = Button::new(self.ident.child("trigger"))
851            .label(self.trigger.clone())
852            .variant(self.trigger_variant)
853            .join(self.trigger_join)
854            .control_size(self.trigger_size)
855            .track_focus(&self.trigger_focus)
856            .when_some(self.trigger_icon, |button, glyph| {
857                match (glyph_only, self.trigger_name.clone()) {
858                    (true, Some(name)) => button.icon_only(glyph, name),
859                    _ => button.icon(glyph),
860                }
861            })
862            .when_some(self.trigger_name.clone(), |button, name| {
863                button.accessible_name(name)
864            })
865            .on_click(move |window, cx| {
866                menu.update(cx, |menu, cx| menu.toggle(window, cx)).ok();
867            })
868            .into_any_element();
869
870        let overlay = self.open.then(|| {
871            if self.pending_focus {
872                // The handle can only take focus once this frame has put it in
873                // the dispatch tree, which is why opening records the intent.
874                self.pending_focus = false;
875                self.focus_handle.focus(window, cx);
876            }
877            let activate: Activate<Self> =
878                Rc::new(|menu: &mut Self, path, window, cx| menu.take(path, window, cx));
879            let menu_id = self.ident.child("menu").semantic_id();
880            let menu_name = self
881                .trigger_name
882                .clone()
883                .unwrap_or_else(|| self.trigger.clone());
884            let content = panels(
885                &self.ident,
886                &self.items,
887                &self.state,
888                menu_id.clone(),
889                &theme,
890                cx,
891                activate,
892            )
893            .track_focus(&self.focus_handle)
894            .on_key_down(cx.listener(Self::on_key_down))
895            .on_mouse_down_out(cx.listener(|menu, _, window, cx| menu.dismiss(window, cx)))
896            .semantic_in(
897                cx,
898                NodeSpec::new(menu_id, Role::Menu)
899                    .parent(self.ident.semantic_id())
900                    .text(menu_name)
901                    .expanded(true)
902                    .focus(&self.focus_handle),
903            );
904
905            Overlay::new(self.ident.child("overlay"))
906                .placement(self.placement)
907                .child(content)
908                .into_any_element()
909        });
910
911        popover::anchored_slot(self.placement, trigger, overlay).semantic_in(
912            cx,
913            NodeSpec::new(self.ident.semantic_id(), Role::Group)
914                .expanded(self.open)
915                .focus(&self.focus_handle),
916        )
917    }
918}
919
920/// What a context menu reports.
921#[derive(Debug, Clone, PartialEq, Eq)]
922pub enum ContextMenuEvent {
923    /// The region was right-clicked. The payload names what was pointed at;
924    /// the host decides what that means for its selection, because opening a
925    /// menu is not choosing anything.
926    Opened(SharedString),
927    Invoked(SharedString),
928    Dismissed,
929    Closed,
930}
931
932impl EventEmitter<ContextMenuEvent> for ContextMenu {}
933
934/// Builds the wrapped region for one frame.
935type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
936
937/// The same menu, opened at the pointer over a region rather than from a
938/// trigger.
939pub struct ContextMenu {
940    ident: Ident,
941    focus_handle: FocusHandle,
942    name: SharedString,
943    target: Option<SharedString>,
944    items: Vec<MenuItem>,
945    content: Option<Content>,
946    open: bool,
947    position: Point<Pixels>,
948    pending_focus: bool,
949    state: MenuState,
950    trap: FocusTrap,
951}
952
953impl std::fmt::Debug for ContextMenu {
954    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
955        formatter
956            .debug_struct("ContextMenu")
957            .field("ident", &self.ident)
958            .field("target", &self.target)
959            .field("items", &self.items.len())
960            .field("open", &self.open)
961            .finish()
962    }
963}
964
965impl ContextMenu {
966    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
967        Self {
968            ident: ident.into(),
969            focus_handle: cx.focus_handle(),
970            name: SharedString::default(),
971            target: None,
972            items: Vec::new(),
973            content: None,
974            open: false,
975            position: gpui::point(px(0.0), px(0.0)),
976            pending_focus: false,
977            state: MenuState::default(),
978            trap: FocusTrap::new(),
979        }
980    }
981
982    pub fn menu(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
983        self.items = items.into_iter().collect();
984        self
985    }
986
987    /// Names the command surface independently of the region under it.
988    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
989        self.name = name.into();
990        self
991    }
992
993    pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
994        self.name = name.into();
995        cx.notify();
996    }
997
998    /// What the wrapped region stands for. Reported when the menu opens, so a
999    /// host can decide whether to select it. Defaults to the menu's own id.
1000    pub fn target(mut self, target: impl Into<SharedString>) -> Self {
1001        self.target = Some(target.into());
1002        self
1003    }
1004
1005    /// Supplies the wrapped region, rebuilt on every frame.
1006    pub fn content(
1007        mut self,
1008        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
1009    ) -> Self {
1010        self.content = Some(Rc::new(content));
1011        self
1012    }
1013
1014    pub fn set_items(&mut self, items: Vec<MenuItem>, cx: &mut Context<Self>) {
1015        self.items = items;
1016        self.state.reset();
1017        cx.notify();
1018    }
1019
1020    pub fn is_open(&self) -> bool {
1021        self.open
1022    }
1023
1024    pub fn position(&self) -> Point<Pixels> {
1025        self.position
1026    }
1027
1028    /// Opens at a window position. Reports the target and nothing else: what
1029    /// is selected stays the host's answer.
1030    pub fn open_at(
1031        &mut self,
1032        position: Point<Pixels>,
1033        window: &mut Window,
1034        cx: &mut Context<Self>,
1035    ) {
1036        self.position = position;
1037        self.state.reset();
1038        self.state.active = first_selectable(&self.items);
1039        if !self.open {
1040            self.open = true;
1041            self.pending_focus = true;
1042            self.trap.engage(window, cx);
1043        }
1044        let target = self
1045            .target
1046            .clone()
1047            .unwrap_or_else(|| self.ident.semantic_id());
1048        cx.emit(ContextMenuEvent::Opened(target));
1049        cx.notify();
1050    }
1051
1052    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1053        if !self.open {
1054            return;
1055        }
1056        self.open = false;
1057        self.pending_focus = false;
1058        self.state.reset();
1059        self.trap.release(window, cx);
1060        cx.emit(ContextMenuEvent::Closed);
1061        cx.notify();
1062    }
1063
1064    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1065        if !self.open {
1066            return;
1067        }
1068        cx.emit(ContextMenuEvent::Dismissed);
1069        self.close(window, cx);
1070    }
1071
1072    fn take(&mut self, path: Vec<usize>, window: &mut Window, cx: &mut Context<Self>) {
1073        match self.state.activate(&self.items, &path) {
1074            Activation::Invoked(id) => {
1075                cx.emit(ContextMenuEvent::Invoked(id));
1076                self.close(window, cx);
1077            }
1078            Activation::OpenedSubmenu => cx.notify(),
1079            Activation::Ignored => {}
1080        }
1081    }
1082
1083    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1084        if !self.open {
1085            return;
1086        }
1087        match handle_key(&mut self.state, &self.items, event) {
1088            Handled::Moved => {
1089                cx.notify();
1090                cx.stop_propagation();
1091            }
1092            Handled::Activate(path) => {
1093                self.take(path, window, cx);
1094                cx.stop_propagation();
1095            }
1096            Handled::Close => {
1097                self.dismiss(window, cx);
1098                cx.stop_propagation();
1099            }
1100            Handled::None => {}
1101        }
1102    }
1103}
1104
1105impl Focusable for ContextMenu {
1106    fn focus_handle(&self, _cx: &App) -> FocusHandle {
1107        self.focus_handle.clone()
1108    }
1109}
1110
1111impl Render for ContextMenu {
1112    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1113        let theme = cx.theme().clone();
1114        let region = self.content.clone().map(|content| content(window, cx));
1115        let menu_id = self.ident.child("menu").semantic_id();
1116
1117        let overlay = self.open.then(|| {
1118            if self.pending_focus {
1119                self.pending_focus = false;
1120                self.focus_handle.focus(window, cx);
1121            }
1122            let activate: Activate<Self> =
1123                Rc::new(|menu: &mut Self, path, window, cx| menu.take(path, window, cx));
1124            let content = panels(
1125                &self.ident,
1126                &self.items,
1127                &self.state,
1128                menu_id.clone(),
1129                &theme,
1130                cx,
1131                activate,
1132            )
1133            .track_focus(&self.focus_handle)
1134            .on_key_down(cx.listener(Self::on_key_down))
1135            .on_mouse_down_out(cx.listener(|menu, _, window, cx| menu.dismiss(window, cx)))
1136            .semantic_in(
1137                cx,
1138                NodeSpec::new(menu_id.clone(), Role::Menu)
1139                    .parent(self.ident.semantic_id())
1140                    .text(self.name.clone())
1141                    .expanded(true)
1142                    .focus(&self.focus_handle),
1143            );
1144
1145            Overlay::new(self.ident.child("overlay"))
1146                .placement(Placement::At(self.position))
1147                .child(content)
1148                .into_any_element()
1149        });
1150
1151        div()
1152            .id(self.ident.element_id())
1153            .on_mouse_down(
1154                MouseButton::Right,
1155                cx.listener(|menu, event: &MouseDownEvent, window, cx| {
1156                    menu.open_at(event.position, window, cx);
1157                    cx.stop_propagation();
1158                }),
1159            )
1160            .children(region)
1161            .children(overlay)
1162            .semantic_in(
1163                cx,
1164                NodeSpec::new(self.ident.semantic_id(), Role::Region).expanded(self.open),
1165            )
1166    }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172
1173    fn items() -> Vec<MenuItem> {
1174        vec![
1175            MenuItem::section("edit.section", "Edit"),
1176            MenuItem::command("edit.undo", "Undo").shortcut("cmd-z"),
1177            MenuItem::separator("edit.rule"),
1178            MenuItem::command("edit.paste", "Paste").disabled(true),
1179            MenuItem::check("edit.wrap", "Wrap lines", true),
1180            MenuItem::submenu(
1181                "edit.share",
1182                "Share",
1183                [
1184                    MenuItem::command("edit.share.link", "Copy link"),
1185                    MenuItem::command("edit.share.mail", "Send by mail"),
1186                ],
1187            ),
1188        ]
1189    }
1190
1191    fn cursor() -> MenuState {
1192        MenuState {
1193            path: Vec::new(),
1194            active: first_selectable(&items()),
1195        }
1196    }
1197
1198    #[test]
1199    fn the_cursor_starts_on_something_that_can_be_taken() {
1200        assert_eq!(cursor().active, Some(1));
1201    }
1202
1203    #[test]
1204    fn moving_steps_over_labels_rules_and_refusals() {
1205        let items = items();
1206        let mut state = cursor();
1207        state.step(&items, 1);
1208        assert_eq!(
1209            state.active,
1210            Some(4),
1211            "the rule and the refusal are skipped"
1212        );
1213        state.step(&items, 1);
1214        assert_eq!(state.active, Some(5));
1215        state.step(&items, 1);
1216        assert_eq!(state.active, Some(1), "the cursor wraps past the section");
1217        state.step(&items, -1);
1218        assert_eq!(state.active, Some(5));
1219    }
1220
1221    #[test]
1222    fn typing_a_letter_jumps_to_the_next_row_that_starts_with_it() {
1223        let items = items();
1224        let mut state = cursor();
1225        assert!(state.jump(&items, 'w'));
1226        assert_eq!(state.active, Some(4));
1227        assert!(!state.jump(&items, 'p'), "a refused row is not jumped to");
1228    }
1229
1230    #[test]
1231    fn a_submenu_is_entered_and_left_without_invoking_anything() {
1232        let items = items();
1233        let mut state = cursor();
1234        state.active = Some(5);
1235        assert!(state.enter(&items));
1236        assert_eq!(state.path, vec![5]);
1237        assert_eq!(state.active, Some(0));
1238
1239        state.step(&items, 1);
1240        assert_eq!(state.active, Some(1));
1241        assert!(state.leave());
1242        assert_eq!(state.path, Vec::<usize>::new());
1243        assert_eq!(state.active, Some(5), "the cursor returns to the submenu");
1244        assert!(!state.leave(), "there is nothing left to fold away");
1245    }
1246
1247    #[test]
1248    fn a_row_that_cannot_be_taken_reports_nothing() {
1249        let items = items();
1250        let mut state = cursor();
1251        assert_eq!(state.activate(&items, &[0]), Activation::Ignored);
1252        assert_eq!(state.activate(&items, &[2]), Activation::Ignored);
1253        assert_eq!(state.activate(&items, &[3]), Activation::Ignored);
1254    }
1255
1256    #[test]
1257    fn taking_a_checkable_row_reports_it_and_changes_nothing() {
1258        let items = items();
1259        let mut state = cursor();
1260        assert_eq!(
1261            state.activate(&items, &[4]),
1262            Activation::Invoked("edit.wrap".into())
1263        );
1264        assert_eq!(
1265            items[4].kind,
1266            MenuItemKind::Check(true),
1267            "the menu does not toggle its own item"
1268        );
1269    }
1270
1271    #[test]
1272    fn acting_in_an_outer_panel_folds_the_open_submenu_away() {
1273        let items = items();
1274        let mut state = cursor();
1275        state.activate(&items, &[5]);
1276        assert_eq!(state.path, vec![5]);
1277        assert_eq!(
1278            state.activate(&items, &[1]),
1279            Activation::Invoked("edit.undo".into())
1280        );
1281        assert_eq!(state.path, Vec::<usize>::new());
1282    }
1283
1284    #[test]
1285    fn an_item_is_addressed_by_identity_at_any_depth() {
1286        let items = items();
1287        assert_eq!(MenuState::path_to(&items, "edit.share"), Some(vec![5]));
1288        assert_eq!(
1289            MenuState::path_to(&items, "edit.share.mail"),
1290            Some(vec![5, 1])
1291        );
1292        assert_eq!(MenuState::path_to(&items, "edit.nothing"), None);
1293    }
1294}