gpui: Add `disabled` state to app menu items (#44191)

Jason Lee created

Continue #39876 to add `disabled` state to `MenuItem`.

Make this change for #44047 ready to have disabled state.

1. Add `disabled` state to `MenuItem`.
2. Add some builder methods to `Menu` and `MenuItem`.
3. Improve `set_menus` method to receive a `impl IntoIterator<Item =
Menu>`.

Release Notes:

- N/A

--

<img width="294" height="204" alt="image"
src="https://github.com/user-attachments/assets/688e0db8-6c4e-4f9b-a832-8228db0e95d8"
/>

```bash
cargo run -p gpui --example set_menus
```

Change summary

crates/gpui/examples/image/image.rs        |   1 
crates/gpui/examples/image_gallery.rs      |   5 
crates/gpui/examples/set_menus.rs          |  42 +++--
crates/gpui/examples/text.rs               |   1 
crates/gpui/src/app.rs                     |   3 
crates/gpui/src/platform/app_menu.rs       | 161 ++++++++++++++++++++++-
crates/gpui_macos/src/platform.rs          |  13 +
crates/livekit_client/examples/test_app.rs |  10 -
crates/storybook/src/app_menus.rs          |   5 
crates/title_bar/src/application_menu.rs   |   7 
crates/ui/src/components/context_menu.rs   |  12 +
crates/zed/src/zed/app_menus.rs            |  59 ++++----
12 files changed, 236 insertions(+), 83 deletions(-)

Detailed changes

crates/gpui/examples/image/image.rs 🔗

@@ -181,6 +181,7 @@ fn run_example() {
         cx.set_menus(vec![Menu {
             name: "Image".into(),
             items: vec![MenuItem::action("Quit", Quit)],
+            disabled: false,
         }]);
 
         let window_options = WindowOptions {

crates/gpui/examples/image_gallery.rs 🔗

@@ -273,10 +273,7 @@ fn run_example() {
         cx.activate(true);
         cx.on_action(|_: &Quit, cx| cx.quit());
         cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
-        cx.set_menus(vec![Menu {
-            name: "Image Gallery".into(),
-            items: vec![MenuItem::action("Quit", Quit)],
-        }]);
+        cx.set_menus([Menu::new("Image Gallery").items([MenuItem::action("Quit", Quit)])]);
 
         let window_options = WindowOptions {
             titlebar: Some(TitlebarOptions {

crates/gpui/examples/set_menus.rs 🔗

@@ -2,7 +2,7 @@
 
 use gpui::{
     App, Context, Global, Menu, MenuItem, SharedString, SystemMenuType, Window, WindowOptions,
-    actions, div, prelude::*, rgb,
+    actions, div, prelude::*,
 };
 use gpui_platform::application;
 
@@ -12,12 +12,12 @@ impl Render for SetMenus {
     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
         div()
             .flex()
-            .bg(rgb(0x2e7d32))
+            .bg(gpui::white())
             .size_full()
             .justify_center()
             .items_center()
             .text_xl()
-            .text_color(rgb(0xffffff))
+            .text_color(gpui::black())
             .child("Set Menus Example")
     }
 }
@@ -28,7 +28,8 @@ fn run_example() {
 
         // Bring the menu bar to the foreground (so you can see the menu bar)
         cx.activate(true);
-        // Register the `quit` function so it can be referenced by the `MenuItem::action` in the menu bar
+        // Register the `quit` function so it can be referenced
+        // by the `MenuItem::action` in the menu bar
         cx.on_action(quit);
         cx.on_action(toggle_check);
         // Add menu items
@@ -91,19 +92,24 @@ impl Global for AppState {}
 
 fn set_app_menus(cx: &mut App) {
     let app_state = cx.global::<AppState>();
-    cx.set_menus(vec![Menu {
-        name: "set_menus".into(),
-        items: vec![
-            MenuItem::os_submenu("Services", SystemMenuType::Services),
-            MenuItem::separator(),
-            MenuItem::action(ViewMode::List, ToggleCheck)
-                .checked(app_state.view_mode == ViewMode::List),
-            MenuItem::action(ViewMode::Grid, ToggleCheck)
-                .checked(app_state.view_mode == ViewMode::Grid),
-            MenuItem::separator(),
-            MenuItem::action("Quit", Quit),
-        ],
-    }]);
+    cx.set_menus([Menu::new("set_menus").items([
+        MenuItem::os_submenu("Services", SystemMenuType::Services),
+        MenuItem::separator(),
+        MenuItem::action("Disabled Item", gpui::NoAction).disabled(true),
+        MenuItem::submenu(Menu::new("Disabled Submenu").disabled(true)),
+        MenuItem::separator(),
+        MenuItem::action("List Mode", ToggleCheck).checked(app_state.view_mode == ViewMode::List),
+        MenuItem::submenu(
+            Menu::new("Mode").items([
+                MenuItem::action(ViewMode::List, ToggleCheck)
+                    .checked(app_state.view_mode == ViewMode::List),
+                MenuItem::action(ViewMode::Grid, ToggleCheck)
+                    .checked(app_state.view_mode == ViewMode::Grid),
+            ]),
+        ),
+        MenuItem::separator(),
+        MenuItem::action("Quit", Quit),
+    ])]);
 }
 
 // Associate actions using the `actions!` macro (or `Action` derive macro)
@@ -111,7 +117,7 @@ actions!(set_menus, [Quit, ToggleCheck]);
 
 // Define the quit function that is registered with the App
 fn quit(_: &Quit, cx: &mut App) {
-    println!("Gracefully quitting the application . . .");
+    println!("Gracefully quitting the application...");
     cx.quit();
 }
 

crates/gpui/examples/text.rs 🔗

@@ -350,6 +350,7 @@ fn run_example() {
     application().run(|cx: &mut App| {
         cx.set_menus(vec![Menu {
             name: "GPUI Typography".into(),
+            disabled: false,
             items: vec![],
         }]);
 

crates/gpui/src/app.rs 🔗

@@ -2072,7 +2072,8 @@ impl App {
     }
 
     /// Sets the menu bar for this application. This will replace any existing menu bar.
-    pub fn set_menus(&self, menus: Vec<Menu>) {
+    pub fn set_menus(&self, menus: impl IntoIterator<Item = Menu>) {
+        let menus: Vec<Menu> = menus.into_iter().collect();
         self.platform.set_menus(menus, &self.keymap.borrow());
     }
 

crates/gpui/src/platform/app_menu.rs 🔗

@@ -7,14 +7,39 @@ pub struct Menu {
 
     /// The items in the menu
     pub items: Vec<MenuItem>,
+
+    /// Whether this menu is disabled
+    pub disabled: bool,
 }
 
 impl Menu {
+    /// Create a new Menu with the given name
+    pub fn new(name: impl Into<SharedString>) -> Self {
+        Self {
+            name: name.into(),
+            items: vec![],
+            disabled: false,
+        }
+    }
+
+    /// Set items to be in this menu
+    pub fn items(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
+        self.items = items.into_iter().collect();
+        self
+    }
+
+    /// Set whether this menu is disabled
+    pub fn disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
+        self
+    }
+
     /// Create an OwnedMenu from this Menu
     pub fn owned(self) -> OwnedMenu {
         OwnedMenu {
             name: self.name.to_string().into(),
             items: self.items.into_iter().map(|item| item.owned()).collect(),
+            disabled: self.disabled,
         }
     }
 }
@@ -72,6 +97,9 @@ pub enum MenuItem {
 
         /// Whether this action is checked
         checked: bool,
+
+        /// Whether this action is disabled
+        disabled: bool,
     },
 }
 
@@ -101,6 +129,7 @@ impl MenuItem {
             action: Box::new(action),
             os_action: None,
             checked: false,
+            disabled: false,
         }
     }
 
@@ -115,6 +144,7 @@ impl MenuItem {
             action: Box::new(action),
             os_action: Some(os_action),
             checked: false,
+            disabled: false,
         }
     }
 
@@ -128,11 +158,13 @@ impl MenuItem {
                 action,
                 os_action,
                 checked,
+                disabled,
             } => OwnedMenuItem::Action {
                 name: name.into(),
                 action,
                 os_action,
                 checked,
+                disabled,
             },
             MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()),
         }
@@ -142,19 +174,49 @@ impl MenuItem {
     ///
     /// Only for [`MenuItem::Action`], otherwise, will be ignored
     pub fn checked(mut self, checked: bool) -> Self {
+        match &mut self {
+            MenuItem::Action { checked: old, .. } => {
+                *old = checked;
+            }
+            _ => {}
+        }
+        self
+    }
+
+    /// Returns whether this menu item is checked
+    ///
+    /// Only for [`MenuItem::Action`], otherwise, returns false
+    #[inline]
+    pub fn is_checked(&self) -> bool {
         match self {
-            MenuItem::Action {
-                action,
-                os_action,
-                name,
-                ..
-            } => MenuItem::Action {
-                name,
-                action,
-                os_action,
-                checked,
-            },
-            _ => self,
+            MenuItem::Action { checked, .. } => *checked,
+            _ => false,
+        }
+    }
+
+    /// Set whether this menu item is disabled
+    pub fn disabled(mut self, disabled: bool) -> Self {
+        match &mut self {
+            MenuItem::Action { disabled: old, .. } => {
+                *old = disabled;
+            }
+            MenuItem::Submenu(submenu) => {
+                submenu.disabled = disabled;
+            }
+            _ => {}
+        }
+        self
+    }
+
+    /// Returns whether this menu item is disabled
+    ///
+    /// Only for [`MenuItem::Action`] and [`MenuItem::Submenu`], otherwise, returns false
+    #[inline]
+    pub fn is_disabled(&self) -> bool {
+        match self {
+            MenuItem::Action { disabled, .. } => *disabled,
+            MenuItem::Submenu(submenu) => submenu.disabled,
+            _ => false,
         }
     }
 }
@@ -179,6 +241,9 @@ pub struct OwnedMenu {
 
     /// The items in the menu
     pub items: Vec<OwnedMenuItem>,
+
+    /// Whether this menu is disabled
+    pub disabled: bool,
 }
 
 /// The different kinds of items that can be in a menu
@@ -206,6 +271,9 @@ pub enum OwnedMenuItem {
 
         /// Whether this action is checked
         checked: bool,
+
+        /// Whether this action is disabled
+        disabled: bool,
     },
 }
 
@@ -219,11 +287,13 @@ impl Clone for OwnedMenuItem {
                 action,
                 os_action,
                 checked,
+                disabled,
             } => OwnedMenuItem::Action {
                 name: name.clone(),
                 action: action.boxed_clone(),
                 os_action: *os_action,
                 checked: *checked,
+                disabled: *disabled,
             },
             OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()),
         }
@@ -287,3 +357,70 @@ pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &App) {
         }
     }));
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::Menu;
+
+    #[test]
+    fn test_menu() {
+        let menu = Menu::new("App")
+            .items(vec![
+                crate::MenuItem::action("Action 1", gpui::NoAction),
+                crate::MenuItem::separator(),
+            ])
+            .disabled(true);
+
+        assert_eq!(menu.name.as_ref(), "App");
+        assert_eq!(menu.items.len(), 2);
+        assert!(menu.disabled);
+    }
+
+    #[test]
+    fn test_menu_item_builder() {
+        use super::MenuItem;
+
+        let item = MenuItem::action("Test Action", gpui::NoAction);
+        assert_eq!(
+            match &item {
+                MenuItem::Action { name, .. } => name.as_ref(),
+                _ => unreachable!(),
+            },
+            "Test Action"
+        );
+        assert!(matches!(
+            item,
+            MenuItem::Action {
+                checked: false,
+                disabled: false,
+                ..
+            }
+        ));
+
+        assert!(
+            MenuItem::action("Test Action", gpui::NoAction)
+                .checked(true)
+                .is_checked()
+        );
+        assert!(
+            MenuItem::action("Test Action", gpui::NoAction)
+                .disabled(true)
+                .is_disabled()
+        );
+
+        let submenu = MenuItem::submenu(super::Menu {
+            name: "Submenu".into(),
+            items: vec![],
+            disabled: true,
+        });
+        assert_eq!(
+            match &submenu {
+                MenuItem::Submenu(menu) => menu.name.as_ref(),
+                _ => unreachable!(),
+            },
+            "Submenu"
+        );
+        assert!(!submenu.is_checked());
+        assert!(submenu.is_disabled());
+    }
+}

crates/gpui_macos/src/platform.rs 🔗

@@ -7,8 +7,8 @@ use block::ConcreteBlock;
 use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
-        NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel,
-        NSVisualEffectState, NSVisualEffectView, NSWindow,
+        NSControl as _, NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel,
+        NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
     },
     base::{BOOL, NO, YES, id, nil, selector},
     foundation::{
@@ -297,6 +297,7 @@ impl MacPlatform {
                     action,
                     os_action,
                     checked,
+                    disabled,
                 } => {
                     // Note that this is intentionally using earlier bindings, whereas typically
                     // later ones take display precedence. See the discussion on
@@ -394,13 +395,18 @@ impl MacPlatform {
                     if *checked {
                         item.setState_(NSVisualEffectState::Active);
                     }
+                    item.setEnabled_(!disabled);
 
                     let tag = actions.len() as NSInteger;
                     let _: () = msg_send![item, setTag: tag];
                     actions.push(action.boxed_clone());
                     item
                 }
-                MenuItem::Submenu(Menu { name, items }) => {
+                MenuItem::Submenu(Menu {
+                    name,
+                    items,
+                    disabled,
+                }) => {
                     let item = NSMenuItem::new(nil).autorelease();
                     let submenu = NSMenu::new(nil).autorelease();
                     submenu.setDelegate_(delegate);
@@ -408,6 +414,7 @@ impl MacPlatform {
                         submenu.addItem_(Self::create_menu_item(item, delegate, actions, keymap));
                     }
                     item.setSubmenu_(submenu);
+                    item.setEnabled_(!disabled);
                     item.setTitle_(ns_string(name));
                     item
                 }

crates/livekit_client/examples/test_app.rs 🔗

@@ -35,15 +35,7 @@ fn main() {
         cx.activate(true);
         cx.on_action(quit);
         cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
-        cx.set_menus(vec![Menu {
-            name: "Zed".into(),
-            items: vec![MenuItem::Action {
-                name: "Quit".into(),
-                action: Box::new(Quit),
-                os_action: None,
-                checked: false,
-            }],
-        }]);
+        cx.set_menus([Menu::new("Zed").items([MenuItem::action("Quit", Quit)])]);
 
         let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or("http://localhost:7880".into());
         let livekit_key = std::env::var("LIVEKIT_KEY").unwrap_or("devkey".into());

crates/storybook/src/app_menus.rs 🔗

@@ -3,8 +3,5 @@ use gpui::{Menu, MenuItem};
 pub fn app_menus() -> Vec<Menu> {
     use crate::actions::Quit;
 
-    vec![Menu {
-        name: "Storybook".into(),
-        items: vec![MenuItem::action("Quit", Quit)],
-    }]
+    vec![Menu::new("Storybook").items([MenuItem::action("Quit", Quit)])]
 }

crates/title_bar/src/application_menu.rs 🔗

@@ -114,8 +114,9 @@ impl ApplicationMenu {
                         name,
                         action,
                         checked,
+                        disabled,
                         ..
-                    } => menu.action_checked(name, action, checked),
+                    } => menu.action_checked_with_disabled(name, action, checked, disabled),
                     OwnedMenuItem::Submenu(submenu) => {
                         submenu
                             .items
@@ -126,8 +127,10 @@ impl ApplicationMenu {
                                     name,
                                     action,
                                     checked,
+                                    disabled,
                                     ..
-                                } => menu.action_checked(name, action, checked),
+                                } => menu
+                                    .action_checked_with_disabled(name, action, checked, disabled),
                                 OwnedMenuItem::Submenu(_) => menu,
                                 OwnedMenuItem::SystemMenu(_) => {
                                     // A system menu doesn't make sense in this context, so ignore it

crates/ui/src/components/context_menu.rs 🔗

@@ -692,10 +692,20 @@ impl ContextMenu {
     }
 
     pub fn action_checked(
+        self,
+        label: impl Into<SharedString>,
+        action: Box<dyn Action>,
+        checked: bool,
+    ) -> Self {
+        self.action_checked_with_disabled(label, action, checked, false)
+    }
+
+    pub fn action_checked_with_disabled(
         mut self,
         label: impl Into<SharedString>,
         action: Box<dyn Action>,
         checked: bool,
+        disabled: bool,
     ) -> Self {
         self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
             toggle: if checked {
@@ -718,7 +728,7 @@ impl ContextMenu {
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
-            disabled: false,
+            disabled,
             documentation_aside: None,
             end_slot_icon: None,
             end_slot_title: None,

crates/zed/src/zed/app_menus.rs 🔗

@@ -31,6 +31,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         MenuItem::action("Toggle All Docks", workspace::ToggleAllDocks),
         MenuItem::submenu(Menu {
             name: "Editor Layout".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action("Split Up", workspace::SplitUp::default()),
                 MenuItem::action("Split Down", workspace::SplitDown::default()),
@@ -60,39 +61,31 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
     vec![
         Menu {
             name: "Zed".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action("About Zed", zed_actions::About),
                 MenuItem::action("Check for Updates", auto_update::Check),
                 MenuItem::separator(),
-                MenuItem::submenu(Menu {
-                    name: "Settings".into(),
-                    items: vec![
-                        MenuItem::action("Open Settings", zed_actions::OpenSettings),
-                        MenuItem::action("Open Settings File", super::OpenSettingsFile),
-                        MenuItem::action("Open Project Settings", zed_actions::OpenProjectSettings),
-                        MenuItem::action(
-                            "Open Project Settings File",
-                            super::OpenProjectSettingsFile,
-                        ),
-                        MenuItem::action("Open Default Settings", super::OpenDefaultSettings),
-                        MenuItem::separator(),
-                        MenuItem::action("Open Keymap", zed_actions::OpenKeymap),
-                        MenuItem::action("Open Keymap File", zed_actions::OpenKeymapFile),
-                        MenuItem::action(
-                            "Open Default Key Bindings",
-                            zed_actions::OpenDefaultKeymap,
-                        ),
-                        MenuItem::separator(),
-                        MenuItem::action(
-                            "Select Theme...",
-                            zed_actions::theme_selector::Toggle::default(),
-                        ),
-                        MenuItem::action(
-                            "Select Icon Theme...",
-                            zed_actions::icon_theme_selector::Toggle::default(),
-                        ),
-                    ],
-                }),
+                MenuItem::submenu(Menu::new("Settings").items([
+                    MenuItem::action("Open Settings", zed_actions::OpenSettings),
+                    MenuItem::action("Open Settings File", super::OpenSettingsFile),
+                    MenuItem::action("Open Project Settings", zed_actions::OpenProjectSettings),
+                    MenuItem::action("Open Project Settings File", super::OpenProjectSettingsFile),
+                    MenuItem::action("Open Default Settings", super::OpenDefaultSettings),
+                    MenuItem::separator(),
+                    MenuItem::action("Open Keymap", zed_actions::OpenKeymap),
+                    MenuItem::action("Open Keymap File", zed_actions::OpenKeymapFile),
+                    MenuItem::action("Open Default Key Bindings", zed_actions::OpenDefaultKeymap),
+                    MenuItem::separator(),
+                    MenuItem::action(
+                        "Select Theme...",
+                        zed_actions::theme_selector::Toggle::default(),
+                    ),
+                    MenuItem::action(
+                        "Select Icon Theme...",
+                        zed_actions::icon_theme_selector::Toggle::default(),
+                    ),
+                ])),
                 MenuItem::separator(),
                 #[cfg(target_os = "macos")]
                 MenuItem::os_submenu("Services", gpui::SystemMenuType::Services),
@@ -113,6 +106,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "File".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action("New", workspace::NewFile),
                 MenuItem::action("New Window", workspace::NewWindow),
@@ -160,6 +154,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "Edit".into(),
+            disabled: false,
             items: vec![
                 MenuItem::os_action("Undo", editor::actions::Undo, OsAction::Undo),
                 MenuItem::os_action("Redo", editor::actions::Redo, OsAction::Redo),
@@ -180,6 +175,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "Selection".into(),
+            disabled: false,
             items: vec![
                 MenuItem::os_action(
                     "Select All",
@@ -227,10 +223,12 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "View".into(),
+            disabled: false,
             items: view_items,
         },
         Menu {
             name: "Go".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action("Back", workspace::GoBack),
                 MenuItem::action("Forward", workspace::GoForward),
@@ -262,6 +260,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "Run".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action(
                     "Spawn Task",
@@ -286,6 +285,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "Window".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action("Minimize", super::Minimize),
                 MenuItem::action("Zoom", super::Zoom),
@@ -294,6 +294,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
         },
         Menu {
             name: "Help".into(),
+            disabled: false,
             items: vec![
                 MenuItem::action(
                     "View Release Notes Locally",