Skip to main content

gpui_authoring/platform/
app_menu.rs

1use crate::{
2    Action, App, KeyContext, Keymap, Keystroke, OsAction, Platform, PlatformMenu, PlatformMenuItem,
3    PlatformOsMenu, SharedString, SystemMenuType,
4};
5use itertools::Itertools as _;
6use std::sync::OnceLock;
7
8/// A menu of the application, either a main menu or a submenu
9pub struct Menu {
10    /// The name of the menu
11    pub name: SharedString,
12
13    /// The items in the menu
14    pub items: Vec<MenuItem>,
15
16    /// Whether this menu is disabled
17    pub disabled: bool,
18}
19
20impl Menu {
21    /// Create a new Menu with the given name
22    pub fn new(name: impl Into<SharedString>) -> Self {
23        Self {
24            name: name.into(),
25            items: vec![],
26            disabled: false,
27        }
28    }
29
30    /// Set items to be in this menu
31    pub fn items(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
32        self.items = items.into_iter().collect();
33        self
34    }
35
36    /// Set whether this menu is disabled
37    pub fn disabled(mut self, disabled: bool) -> Self {
38        self.disabled = disabled;
39        self
40    }
41
42    /// Create an OwnedMenu from this Menu
43    pub fn owned(self) -> OwnedMenu {
44        OwnedMenu {
45            name: self.name.to_string().into(),
46            items: self.items.into_iter().map(|item| item.owned()).collect(),
47            disabled: self.disabled,
48        }
49    }
50}
51
52/// OS menus are menus that are recognized by the operating system
53/// This allows the operating system to provide specialized items for
54/// these menus
55pub struct OsMenu {
56    /// The name of the menu
57    pub name: SharedString,
58
59    /// The type of menu
60    pub menu_type: SystemMenuType,
61}
62
63impl OsMenu {
64    /// Create an OwnedOsMenu from this OsMenu
65    pub fn owned(self) -> OwnedOsMenu {
66        OwnedOsMenu {
67            name: self.name.to_string().into(),
68            menu_type: self.menu_type,
69        }
70    }
71}
72
73/// The different kinds of items that can be in a menu
74pub enum MenuItem {
75    /// A separator between items
76    Separator,
77
78    /// A submenu
79    Submenu(Menu),
80
81    /// A menu, managed by the system (for example, the Services menu on macOS)
82    SystemMenu(OsMenu),
83
84    /// An action that can be performed
85    Action {
86        /// The name of this menu item
87        name: SharedString,
88
89        /// The action to perform when this menu item is selected
90        action: Box<dyn Action>,
91
92        /// The OS Action that corresponds to this action, if any
93        /// See [`OsAction`] for more information
94        os_action: Option<OsAction>,
95
96        /// Whether this action is checked
97        checked: bool,
98
99        /// Whether this action is disabled
100        disabled: bool,
101    },
102}
103
104impl MenuItem {
105    /// Creates a new menu item that is a separator
106    pub fn separator() -> Self {
107        Self::Separator
108    }
109
110    /// Creates a new menu item that is a submenu
111    pub fn submenu(menu: Menu) -> Self {
112        Self::Submenu(menu)
113    }
114
115    /// Creates a new submenu that is populated by the OS
116    pub fn os_submenu(name: impl Into<SharedString>, menu_type: SystemMenuType) -> Self {
117        Self::SystemMenu(OsMenu {
118            name: name.into(),
119            menu_type,
120        })
121    }
122
123    /// Creates a new menu item that invokes an action
124    pub fn action(name: impl Into<SharedString>, action: impl Action) -> Self {
125        Self::Action {
126            name: name.into(),
127            action: Box::new(action),
128            os_action: None,
129            checked: false,
130            disabled: false,
131        }
132    }
133
134    /// Creates a new menu item that invokes an action and has an OS action
135    pub fn os_action(
136        name: impl Into<SharedString>,
137        action: impl Action,
138        os_action: OsAction,
139    ) -> Self {
140        Self::Action {
141            name: name.into(),
142            action: Box::new(action),
143            os_action: Some(os_action),
144            checked: false,
145            disabled: false,
146        }
147    }
148
149    /// Create an OwnedMenuItem from this MenuItem
150    pub fn owned(self) -> OwnedMenuItem {
151        match self {
152            MenuItem::Separator => OwnedMenuItem::Separator,
153            MenuItem::Submenu(submenu) => OwnedMenuItem::Submenu(submenu.owned()),
154            MenuItem::Action {
155                name,
156                action,
157                os_action,
158                checked,
159                disabled,
160            } => OwnedMenuItem::Action {
161                name: name.into(),
162                action,
163                os_action,
164                checked,
165                disabled,
166            },
167            MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()),
168        }
169    }
170
171    /// Set whether this menu item is checked
172    ///
173    /// Only for [`MenuItem::Action`], otherwise, will be ignored
174    pub fn checked(mut self, checked: bool) -> Self {
175        match &mut self {
176            MenuItem::Action { checked: old, .. } => {
177                *old = checked;
178            }
179            _ => {}
180        }
181        self
182    }
183
184    /// Returns whether this menu item is checked
185    ///
186    /// Only for [`MenuItem::Action`], otherwise, returns false
187    #[inline]
188    pub fn is_checked(&self) -> bool {
189        match self {
190            MenuItem::Action { checked, .. } => *checked,
191            _ => false,
192        }
193    }
194
195    /// Set whether this menu item is disabled
196    pub fn disabled(mut self, disabled: bool) -> Self {
197        match &mut self {
198            MenuItem::Action { disabled: old, .. } => {
199                *old = disabled;
200            }
201            MenuItem::Submenu(submenu) => {
202                submenu.disabled = disabled;
203            }
204            _ => {}
205        }
206        self
207    }
208
209    /// Returns whether this menu item is disabled
210    ///
211    /// Only for [`MenuItem::Action`] and [`MenuItem::Submenu`], otherwise, returns false
212    #[inline]
213    pub fn is_disabled(&self) -> bool {
214        match self {
215            MenuItem::Action { disabled, .. } => *disabled,
216            MenuItem::Submenu(submenu) => submenu.disabled,
217            _ => false,
218        }
219    }
220}
221
222/// OS menus are menus that are recognized by the operating system
223/// This allows the operating system to provide specialized items for
224/// these menus
225#[derive(Clone)]
226pub struct OwnedOsMenu {
227    /// The name of the menu
228    pub name: SharedString,
229
230    /// The type of menu
231    pub menu_type: SystemMenuType,
232}
233
234/// A menu of the application, either a main menu or a submenu
235#[derive(Clone)]
236pub struct OwnedMenu {
237    /// The name of the menu
238    pub name: SharedString,
239
240    /// The items in the menu
241    pub items: Vec<OwnedMenuItem>,
242
243    /// Whether this menu is disabled
244    pub disabled: bool,
245}
246
247/// The different kinds of items that can be in a menu
248pub enum OwnedMenuItem {
249    /// A separator between items
250    Separator,
251
252    /// A submenu
253    Submenu(OwnedMenu),
254
255    /// A menu, managed by the system (for example, the Services menu on macOS)
256    SystemMenu(OwnedOsMenu),
257
258    /// An action that can be performed
259    Action {
260        /// The name of this menu item
261        name: String,
262
263        /// The action to perform when this menu item is selected
264        action: Box<dyn Action>,
265
266        /// The OS Action that corresponds to this action, if any
267        /// See [`OsAction`] for more information
268        os_action: Option<OsAction>,
269
270        /// Whether this action is checked
271        checked: bool,
272
273        /// Whether this action is disabled
274        disabled: bool,
275    },
276}
277
278impl Clone for OwnedMenuItem {
279    fn clone(&self) -> Self {
280        match self {
281            OwnedMenuItem::Separator => OwnedMenuItem::Separator,
282            OwnedMenuItem::Submenu(submenu) => OwnedMenuItem::Submenu(submenu.clone()),
283            OwnedMenuItem::Action {
284                name,
285                action,
286                os_action,
287                checked,
288                disabled,
289            } => OwnedMenuItem::Action {
290                name: name.clone(),
291                action: action.boxed_clone(),
292                os_action: *os_action,
293                checked: *checked,
294                disabled: *disabled,
295            },
296            OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()),
297        }
298    }
299}
300
301pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &App) {
302    platform.on_will_open_app_menu(Box::new({
303        let cx = cx.to_async();
304        move || {
305            if let Some(app) = cx.app.upgrade() {
306                app.borrow_mut().update(|cx| cx.clear_pending_keystrokes());
307            }
308        }
309    }));
310
311    platform.on_validate_app_menu_command(Box::new({
312        let cx = cx.to_async();
313        move |command_id| {
314            cx.app
315                .upgrade()
316                .map(|app| {
317                    app.borrow_mut()
318                        .update(|cx| cx.is_menu_command_available(command_id))
319                })
320                .unwrap_or(false)
321        }
322    }));
323
324    platform.on_app_menu_action(Box::new({
325        let cx = cx.to_async();
326        move |command_id| {
327            if let Some(app) = cx.app.upgrade() {
328                app.borrow_mut()
329                    .update(|cx| cx.dispatch_menu_command(command_id));
330            }
331        }
332    }));
333}
334
335/// Resolves a menu tree into the platform vocabulary, recording each action in
336/// `actions` such that its index is the item's [`MenuCommandId`].
337pub(crate) fn resolve_menus(
338    menus: &[OwnedMenu],
339    keymap: &Keymap,
340    actions: &mut Vec<Box<dyn Action>>,
341) -> Vec<PlatformMenu> {
342    menus
343        .iter()
344        .map(|menu| resolve_menu(menu, keymap, actions))
345        .collect()
346}
347
348/// Resolves a dock menu into the platform vocabulary, appending its actions to
349/// the same `actions` registry as [`resolve_menus`].
350pub(crate) fn resolve_dock_menu(
351    items: &[OwnedMenuItem],
352    keymap: &Keymap,
353    actions: &mut Vec<Box<dyn Action>>,
354) -> Vec<PlatformMenuItem> {
355    items
356        .iter()
357        .map(|item| resolve_item(item, keymap, actions))
358        .collect()
359}
360
361fn resolve_menu(
362    menu: &OwnedMenu,
363    keymap: &Keymap,
364    actions: &mut Vec<Box<dyn Action>>,
365) -> PlatformMenu {
366    PlatformMenu {
367        name: menu.name.clone(),
368        items: menu
369            .items
370            .iter()
371            .map(|item| resolve_item(item, keymap, actions))
372            .collect(),
373        disabled: menu.disabled,
374    }
375}
376
377fn resolve_item(
378    item: &OwnedMenuItem,
379    keymap: &Keymap,
380    actions: &mut Vec<Box<dyn Action>>,
381) -> PlatformMenuItem {
382    match item {
383        OwnedMenuItem::Separator => PlatformMenuItem::Separator,
384        OwnedMenuItem::Submenu(menu) => {
385            PlatformMenuItem::Submenu(resolve_menu(menu, keymap, actions))
386        }
387        OwnedMenuItem::SystemMenu(OwnedOsMenu { name, menu_type }) => {
388            PlatformMenuItem::SystemMenu(PlatformOsMenu {
389                name: name.clone(),
390                menu_type: *menu_type,
391            })
392        }
393        OwnedMenuItem::Action {
394            name,
395            action,
396            os_action,
397            checked,
398            disabled,
399        } => {
400            let keystroke = resolve_keystroke(action.as_ref(), keymap);
401            let command_id = actions.len();
402            actions.push(action.boxed_clone());
403            PlatformMenuItem::Action {
404                name: name.clone(),
405                command_id,
406                keystroke,
407                os_action: *os_action,
408                checked: *checked,
409                disabled: *disabled,
410            }
411        }
412    }
413}
414
415/// Finds the accelerator to display for `action`, mirroring the precedence the
416/// macOS backend used: prefer the earliest binding whose predicate holds in a
417/// default Workspace/Pane/Editor context, and only show an accelerator when the
418/// chosen binding is a single keystroke.
419///
420/// See the discussion on <https://github.com/zed-industries/zed/issues/23621>.
421fn resolve_keystroke(action: &dyn Action, keymap: &Keymap) -> Option<Keystroke> {
422    static DEFAULT_CONTEXT: OnceLock<Vec<KeyContext>> = OnceLock::new();
423
424    let keystrokes = keymap
425        .bindings_for_action(action)
426        .find_or_first(|binding| {
427            binding.predicate().is_none_or(|predicate| {
428                predicate.eval(DEFAULT_CONTEXT.get_or_init(|| {
429                    let mut workspace_context = KeyContext::new_with_defaults();
430                    workspace_context.add("Workspace");
431                    let mut pane_context = KeyContext::new_with_defaults();
432                    pane_context.add("Pane");
433                    let mut editor_context = KeyContext::new_with_defaults();
434                    editor_context.add("Editor");
435
436                    pane_context.extend(&editor_context);
437                    workspace_context.extend(&pane_context);
438                    vec![workspace_context]
439                }))
440            })
441        })
442        .map(|binding| binding.keystrokes());
443
444    match keystrokes {
445        Some(keystrokes) if keystrokes.len() == 1 => keystrokes
446            .into_iter()
447            .next()
448            .map(|keystroke| keystroke.inner().clone()),
449        _ => None,
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use crate::Menu;
456
457    #[test]
458    fn test_menu() {
459        let menu = Menu::new("App")
460            .items(vec![
461                crate::MenuItem::action("Action 1", gpui::NoAction),
462                crate::MenuItem::separator(),
463            ])
464            .disabled(true);
465
466        assert_eq!(menu.name.as_ref(), "App");
467        assert_eq!(menu.items.len(), 2);
468        assert!(menu.disabled);
469    }
470
471    #[test]
472    fn test_menu_item_builder() {
473        use super::MenuItem;
474
475        let item = MenuItem::action("Test Action", gpui::NoAction);
476        assert_eq!(
477            match &item {
478                MenuItem::Action { name, .. } => name.as_ref(),
479                _ => unreachable!(),
480            },
481            "Test Action"
482        );
483        assert!(matches!(
484            item,
485            MenuItem::Action {
486                checked: false,
487                disabled: false,
488                ..
489            }
490        ));
491
492        assert!(
493            MenuItem::action("Test Action", gpui::NoAction)
494                .checked(true)
495                .is_checked()
496        );
497        assert!(
498            MenuItem::action("Test Action", gpui::NoAction)
499                .disabled(true)
500                .is_disabled()
501        );
502
503        let submenu = MenuItem::submenu(super::Menu {
504            name: "Submenu".into(),
505            items: vec![],
506            disabled: true,
507        });
508        assert_eq!(
509            match &submenu {
510                MenuItem::Submenu(menu) => menu.name.as_ref(),
511                _ => unreachable!(),
512            },
513            "Submenu"
514        );
515        assert!(!submenu.is_checked());
516        assert!(submenu.is_disabled());
517    }
518}