gpui: Add to support check state to MenuItem (#39876)

Jason Lee created

Release Notes:

- N/A

---


https://github.com/user-attachments/assets/d46b77ae-88ba-43da-93ad-3656a7fecaf9

The system menu is only support for macOS, so here just modify the macOS
platform special code.

The Windows, Linux used `ApplicationMenu`, I have already added
`checked` option to Zed's ContextMenu.

Then later when this PR merged, we can improve "View" menu to show check
state to panels (Project Panel, Outline Panel, ...).

Change summary

crates/gpui/examples/set_menus.rs          | 80 ++++++++++++++++++++---
crates/gpui/src/platform/app_menu.rs       | 36 ++++++++++
crates/gpui/src/platform/mac/platform.rs   |  7 +
crates/livekit_client/examples/test_app.rs |  1 
crates/title_bar/src/application_menu.rs   | 16 +++-
crates/ui/src/components/context_menu.rs   | 17 ++++
6 files changed, 137 insertions(+), 20 deletions(-)

Detailed changes

crates/gpui/examples/set_menus.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{
-    App, Application, Context, Menu, MenuItem, SystemMenuType, Window, WindowOptions, actions, div,
-    prelude::*, rgb,
+    App, Application, Context, Global, Menu, MenuItem, SharedString, SystemMenuType, Window,
+    WindowOptions, actions, div, prelude::*, rgb,
 };
 
 struct SetMenus;
@@ -21,29 +21,87 @@ impl Render for SetMenus {
 
 fn main() {
     Application::new().run(|cx: &mut App| {
+        cx.set_global(AppState::new());
+
         // 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
         cx.on_action(quit);
+        cx.on_action(toggle_check);
         // Add menu items
-        cx.set_menus(vec![Menu {
-            name: "set_menus".into(),
-            items: vec![
-                MenuItem::os_submenu("Services", SystemMenuType::Services),
-                MenuItem::separator(),
-                MenuItem::action("Quit", Quit),
-            ],
-        }]);
+        set_app_menus(cx);
         cx.open_window(WindowOptions::default(), |_, cx| cx.new(|_| SetMenus {}))
             .unwrap();
     });
 }
 
+#[derive(PartialEq)]
+enum ViewMode {
+    List,
+    Grid,
+}
+
+impl ViewMode {
+    fn toggle(&mut self) {
+        *self = match self {
+            ViewMode::List => ViewMode::Grid,
+            ViewMode::Grid => ViewMode::List,
+        }
+    }
+}
+
+impl Into<SharedString> for ViewMode {
+    fn into(self) -> SharedString {
+        match self {
+            ViewMode::List => "List",
+            ViewMode::Grid => "Grid",
+        }
+        .into()
+    }
+}
+
+struct AppState {
+    view_mode: ViewMode,
+}
+
+impl AppState {
+    fn new() -> Self {
+        Self {
+            view_mode: ViewMode::List,
+        }
+    }
+}
+
+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),
+        ],
+    }]);
+}
+
 // Associate actions using the `actions!` macro (or `Action` derive macro)
-actions!(set_menus, [Quit]);
+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 . . .");
     cx.quit();
 }
+
+fn toggle_check(_: &ToggleCheck, cx: &mut App) {
+    let app_state = cx.global_mut::<AppState>();
+    app_state.view_mode.toggle();
+    set_app_menus(cx);
+}

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

@@ -64,12 +64,15 @@ pub enum MenuItem {
         /// The name of this menu item
         name: SharedString,
 
-        /// the action to perform when this menu item is selected
+        /// The action to perform when this menu item is selected
         action: Box<dyn Action>,
 
         /// The OS Action that corresponds to this action, if any
         /// See [`OsAction`] for more information
         os_action: Option<OsAction>,
+
+        /// Whether this action is checked
+        checked: bool,
     },
 }
 
@@ -98,6 +101,7 @@ impl MenuItem {
             name: name.into(),
             action: Box::new(action),
             os_action: None,
+            checked: false,
         }
     }
 
@@ -111,6 +115,7 @@ impl MenuItem {
             name: name.into(),
             action: Box::new(action),
             os_action: Some(os_action),
+            checked: false,
         }
     }
 
@@ -123,14 +128,36 @@ impl MenuItem {
                 name,
                 action,
                 os_action,
+                checked,
             } => OwnedMenuItem::Action {
                 name: name.into(),
                 action,
                 os_action,
+                checked,
             },
             MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()),
         }
     }
+
+    /// Set whether this menu item is checked
+    ///
+    /// Only for [`MenuItem::Action`], otherwise, will be ignored
+    pub fn checked(mut self, checked: bool) -> Self {
+        match self {
+            MenuItem::Action {
+                action,
+                os_action,
+                name,
+                ..
+            } => MenuItem::Action {
+                name,
+                action,
+                os_action,
+                checked,
+            },
+            _ => self,
+        }
+    }
 }
 
 /// OS menus are menus that are recognized by the operating system
@@ -171,12 +198,15 @@ pub enum OwnedMenuItem {
         /// The name of this menu item
         name: String,
 
-        /// the action to perform when this menu item is selected
+        /// The action to perform when this menu item is selected
         action: Box<dyn Action>,
 
         /// The OS Action that corresponds to this action, if any
         /// See [`OsAction`] for more information
         os_action: Option<OsAction>,
+
+        /// Whether this action is checked
+        checked: bool,
     },
 }
 
@@ -189,10 +219,12 @@ impl Clone for OwnedMenuItem {
                 name,
                 action,
                 os_action,
+                checked,
             } => OwnedMenuItem::Action {
                 name: name.clone(),
                 action: action.boxed_clone(),
                 os_action: *os_action,
+                checked: *checked,
             },
             OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()),
         }

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

@@ -19,7 +19,7 @@ use cocoa::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
         NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
         NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString,
-        NSPasteboardTypeTIFF, NSSavePanel, NSWindow,
+        NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
     },
     base::{BOOL, NO, YES, id, nil, selector},
     foundation::{
@@ -315,6 +315,7 @@ impl MacPlatform {
                     name,
                     action,
                     os_action,
+                    checked,
                 } => {
                     // Note that this is intentionally using earlier bindings, whereas typically
                     // later ones take display precedence. See the discussion on
@@ -409,6 +410,10 @@ impl MacPlatform {
                             .autorelease();
                     }
 
+                    if *checked {
+                        item.setState_(NSVisualEffectState::Active);
+                    }
+
                     let tag = actions.len() as NSInteger;
                     let _: () = msg_send![item, setTag: tag];
                     actions.push(action.boxed_clone());

crates/title_bar/src/application_menu.rs 🔗

@@ -110,16 +110,24 @@ impl ApplicationMenu {
                 .into_iter()
                 .fold(menu, |menu, item| match item {
                     OwnedMenuItem::Separator => menu.separator(),
-                    OwnedMenuItem::Action { name, action, .. } => menu.action(name, action),
+                    OwnedMenuItem::Action {
+                        name,
+                        action,
+                        checked,
+                        ..
+                    } => menu.action_checked(name, action, checked),
                     OwnedMenuItem::Submenu(submenu) => {
                         submenu
                             .items
                             .into_iter()
                             .fold(menu, |menu, item| match item {
                                 OwnedMenuItem::Separator => menu.separator(),
-                                OwnedMenuItem::Action { name, action, .. } => {
-                                    menu.action(name, action)
-                                }
+                                OwnedMenuItem::Action {
+                                    name,
+                                    action,
+                                    checked,
+                                    ..
+                                } => menu.action_checked(name, action, checked),
                                 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 🔗

@@ -542,9 +542,22 @@ impl ContextMenu {
         self
     }
 
-    pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
+    pub fn action(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
+        self.action_checked(label, action, false)
+    }
+
+    pub fn action_checked(
+        mut self,
+        label: impl Into<SharedString>,
+        action: Box<dyn Action>,
+        checked: bool,
+    ) -> Self {
         self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
-            toggle: None,
+            toggle: if checked {
+                Some((IconPosition::Start, true))
+            } else {
+                None
+            },
             label: label.into(),
             action: Some(action.boxed_clone()),
             handler: Rc::new(move |context, window, cx| {