StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use cocoa::appkit::{NSApp, NSEventModifierFlags, NSMenu, NSMenuItem}; |
| 2 | use cocoa::base::selector; |
| 3 | use cocoa::{ |
| 4 | appkit::{ |
| 5 | NSDownArrowFunctionKey, NSEndFunctionKey, NSF10FunctionKey, NSF11FunctionKey, |
| 6 | NSF12FunctionKey, NSF13FunctionKey, NSF14FunctionKey, NSF15FunctionKey, NSF16FunctionKey, |
| 7 | NSF17FunctionKey, NSF18FunctionKey, NSF19FunctionKey, NSF1FunctionKey, NSF20FunctionKey, |
| 8 | NSF2FunctionKey, NSF3FunctionKey, NSF4FunctionKey, NSF5FunctionKey, NSF6FunctionKey, |
| 9 | NSF7FunctionKey, NSF8FunctionKey, NSF9FunctionKey, NSHomeFunctionKey, NSInsertFunctionKey, |
| 10 | NSLeftArrowFunctionKey, NSPageDownFunctionKey, NSPageUpFunctionKey, |
| 11 | NSRightArrowFunctionKey, NSUpArrowFunctionKey, |
| 12 | }, |
| 13 | base::{id, nil}, |
| 14 | foundation::{NSArray, NSAutoreleasePool, NSInteger}, |
| 15 | }; |
| 16 | use lazy_static::lazy_static; |
| 17 | use objc::runtime::{NO, YES}; |
| 18 | use objc::{msg_send, sel, sel_impl}; |
| 19 | use std::{boxed::Box, cell::RefCell, collections::HashMap, ffi::c_void, rc::Rc}; |
| 20 | use strato_ui_core::actions::StandardAction; |
| 21 | use strato_ui_core::keymap::Keystroke; |
| 22 | use strato_ui_core::platform::menu::{ |
| 23 | ItemTriggeredCallback, Menu, MenuBar, MenuItem, MenuItemProperties, MenuItemPropertyChanges, |
| 24 | UpdateMenuItemCallback, |
| 25 | }; |
| 26 | |
| 27 | use super::app::callback_dispatcher; |
| 28 | use super::make_nsstring; |
| 29 | |
| 30 | lazy_static! { |
| 31 | /// A mac-menu-specific map of key names to special characters used for the keyboard shortcuts |
| 32 | /// in the mac menus |
| 33 | static ref MENU_KEY_EQUIVALENTS: HashMap<&'static str, char> = { |
| 34 | fn to_char(key: u16) -> char { |
| 35 | char::from_u32(key.into()).unwrap() |
| 36 | } |
| 37 | |
| 38 | HashMap::from([ |
| 39 | ("up", to_char(NSUpArrowFunctionKey)), |
| 40 | ("down", to_char(NSDownArrowFunctionKey)), |
| 41 | ("left", to_char(NSLeftArrowFunctionKey)), |
| 42 | ("right", to_char(NSRightArrowFunctionKey)), |
| 43 | ("home", to_char(NSHomeFunctionKey)), |
| 44 | ("end", to_char(NSEndFunctionKey)), |
| 45 | ("pageup", to_char(NSPageUpFunctionKey)), |
| 46 | ("pagedown", to_char(NSPageDownFunctionKey)), |
| 47 | ("enter", '\n'), |
| 48 | ("tab", '\t'), |
| 49 | ("insert", to_char(NSInsertFunctionKey)), |
| 50 | ("f1", to_char(NSF1FunctionKey)), |
| 51 | ("f2", to_char(NSF2FunctionKey)), |
| 52 | ("f3", to_char(NSF3FunctionKey)), |
| 53 | ("f4", to_char(NSF4FunctionKey)), |
| 54 | ("f5", to_char(NSF5FunctionKey)), |
| 55 | ("f6", to_char(NSF6FunctionKey)), |
| 56 | ("f7", to_char(NSF7FunctionKey)), |
| 57 | ("f8", to_char(NSF8FunctionKey)), |
| 58 | ("f9", to_char(NSF9FunctionKey)), |
| 59 | ("f10", to_char(NSF10FunctionKey)), |
| 60 | ("f11", to_char(NSF11FunctionKey)), |
| 61 | ("f12", to_char(NSF12FunctionKey)), |
| 62 | ("f13", to_char(NSF13FunctionKey)), |
| 63 | ("f14", to_char(NSF14FunctionKey)), |
| 64 | ("f15", to_char(NSF15FunctionKey)), |
| 65 | ("f16", to_char(NSF16FunctionKey)), |
| 66 | ("f17", to_char(NSF17FunctionKey)), |
| 67 | ("f18", to_char(NSF18FunctionKey)), |
| 68 | ("f19", to_char(NSF19FunctionKey)), |
| 69 | ("f20", to_char(NSF20FunctionKey)), |
| 70 | // The following values are the inverse of `ui/src/platform/mac/event.rs` mappings |
| 71 | ("numpadenter", to_char(0x03)), |
| 72 | ("escape", to_char(0x1b)), |
| 73 | // Note: Backspace and Delete have different characters for the menu key equivalents |
| 74 | // than they send when they are pressed. See the discussion in the Apple docs: |
| 75 | // https://developer.apple.com/documentation/appkit/nsmenuitem/1514842-keyequivalent?language=objc |
| 76 | ("backspace", to_char(0x08)), |
| 77 | ("delete", to_char(0x7F)), |
| 78 | ]) |
| 79 | }; |
| 80 | } |
| 81 | |
| 82 | /// Data associated with a custom NSMenuItem. |
| 83 | struct MenuItemData { |
| 84 | /// Properties of the menu item. |
| 85 | /// These could be computed from the menu item but we trust AppKit does not change them. |
| 86 | props: RefCell<MenuItemProperties>, |
| 87 | |
| 88 | /// Callback when the menu item is triggered by the user. |
| 89 | triggered: ItemTriggeredCallback, |
| 90 | |
| 91 | /// Callback when the menu item needs updating. |
| 92 | update: UpdateMenuItemCallback, |
| 93 | } |
| 94 | |
| 95 | impl MenuItemData { |
| 96 | /// Convert self to a Cocoa context pointer, including the refcount. |
| 97 | /// This should be balanced by consume_cocoa_context. |
| 98 | fn into_context(self: Rc<MenuItemData>) -> *mut c_void { |
| 99 | Box::into_raw(Box::new(self)) as *mut c_void |
| 100 | } |
| 101 | |
| 102 | /// Read out from the Cocoa context pointer, without consuming its refcount. |
| 103 | fn read_context(ctx: *const c_void) -> Rc<MenuItemData> { |
| 104 | unsafe { |
| 105 | let ptr = &*(ctx as *const Rc<MenuItemData>); |
| 106 | ptr.clone() |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Balances a call from to_cocoa_context. |
| 111 | fn consume_context(ctx: *mut c_void) { |
| 112 | unsafe { std::mem::drop(Box::from_raw(ctx as *mut Rc<MenuItemData>)) } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// We hand Cocoa a void* which is really an unwrapped Box<Rc<MenuItemData>>. |
| 117 | /// The NSMenuItem logically holds a reference count on this Rc, which is balanced in our dealloc callback below. |
| 118 | /// The following functions are invoked from Cocoa. |
| 119 | #[no_mangle] |
| 120 | extern "C-unwind" fn warp_menu_item_needs_update(item: id, ctx: *mut c_void) { |
| 121 | let ctx = MenuItemData::read_context(ctx); |
| 122 | let props: MenuItemProperties = ctx.props.borrow().clone(); |
| 123 | let func = &ctx.update; |
| 124 | |
| 125 | let mut updated_properties = callback_dispatcher().update_menu_item(|ctx| func(&props, ctx)); |
| 126 | |
| 127 | // Always re-apply the disabled state even when the updater has no opinion. |
| 128 | // AppKit's modal sessions (e.g. [NSAlert runModal]) can externally disable |
| 129 | // menu items, and items whose updaters return `disabled: None` would never |
| 130 | // call setEnabled: to restore the correct state. On macOS with the quake |
| 131 | // mode (non-activating panel) window, this results in permanently disabled |
| 132 | // items after a modal is dismissed. Default to enabled — updaters that want |
| 133 | // an item disabled must say so explicitly. |
| 134 | if updated_properties.disabled.is_none() { |
| 135 | updated_properties.disabled = Some(false); |
| 136 | } |
| 137 | |
| 138 | // Update any changed properties. |
| 139 | ctx.props.borrow_mut().apply(&updated_properties); |
| 140 | unsafe { apply_changes(updated_properties, item) }; |
| 141 | } |
| 142 | |
| 143 | #[no_mangle] |
| 144 | extern "C-unwind" fn warp_menu_item_triggered(_item: id, ctx: *mut c_void) { |
| 145 | let func = &MenuItemData::read_context(ctx).triggered; |
| 146 | callback_dispatcher().menu_item_triggered(func); |
| 147 | } |
| 148 | |
| 149 | #[no_mangle] |
| 150 | extern "C-unwind" fn warp_menu_item_deallocated(ctx: *mut c_void) { |
| 151 | MenuItemData::consume_context(ctx) |
| 152 | } |
| 153 | |
| 154 | // Declarations of functions implemented in ObjC files. |
| 155 | // These signatures must be manually synced - there's no type checking here. |
| 156 | extern "C" { |
| 157 | fn make_delegated_menu(title: id) -> id; |
| 158 | fn make_warp_custom_menu_item(ctx: *mut c_void) -> id; |
| 159 | fn set_menu_item_submenu(item: id, submenu: id); |
| 160 | fn make_services_menu_item() -> id; |
| 161 | } |
| 162 | |
| 163 | struct StandardMenuItemProperties { |
| 164 | title: &'static str, // menu item title |
| 165 | action: &'static str, // the selector name |
| 166 | shortcut: &'static str, // the key equivalent string, or empty for none |
| 167 | modifiers: NSEventModifierFlags, |
| 168 | } |
| 169 | |
| 170 | // Get properties from a standard action. |
| 171 | fn resolve_standard_action(action: StandardAction) -> StandardMenuItemProperties { |
| 172 | let cmd = NSEventModifierFlags::NSCommandKeyMask; |
| 173 | let option = NSEventModifierFlags::NSAlternateKeyMask; |
| 174 | let ctrl = NSEventModifierFlags::NSControlKeyMask; |
| 175 | let none = NSEventModifierFlags::empty(); |
| 176 | |
| 177 | fn make( |
| 178 | title: &'static str, |
| 179 | action: &'static str, |
| 180 | modifiers: NSEventModifierFlags, |
| 181 | shortcut: &'static str, |
| 182 | ) -> StandardMenuItemProperties { |
| 183 | StandardMenuItemProperties { |
| 184 | title, |
| 185 | action, |
| 186 | shortcut, |
| 187 | modifiers, |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | match action { |
| 192 | StandardAction::Close => make("Close Window", "performClose:", none, ""), |
| 193 | StandardAction::Quit => make("Quit Warp", "terminate:", cmd, "q"), |
| 194 | StandardAction::Hide => make("Hide Warp", "hide:", cmd, "h"), |
| 195 | StandardAction::HideOtherApps => { |
| 196 | make("Hide Others", "hideOtherApplications:", cmd | option, "h") |
| 197 | } |
| 198 | StandardAction::ShowAllApps => make("Show All", "unhideAllApplications:", none, ""), |
| 199 | StandardAction::Minimize => make("Minimize", "performMiniaturize:", cmd, "m"), |
| 200 | StandardAction::Zoom => make("Zoom", "performZoom:", none, ""), |
| 201 | StandardAction::BringAllToFront => make("Bring All to Front", "arrangeInFront:", none, ""), |
| 202 | StandardAction::ToggleFullScreen => { |
| 203 | make("ToggleFullScreen", "toggleFullScreen:", cmd | ctrl, "f") |
| 204 | } |
| 205 | StandardAction::Paste => make("Paste", "paste:", none, ""), |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | /// Determine the key equivalent for the given keystroke |
| 210 | fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (id, NSEventModifierFlags) { |
| 211 | let mut flags = NSEventModifierFlags::empty(); |
| 212 | |
| 213 | let keystroke = match keystroke { |
| 214 | Some(value) => value, |
| 215 | None => return (make_nsstring(""), flags), |
| 216 | }; |
| 217 | |
| 218 | let key_equivalent = match MENU_KEY_EQUIVALENTS.get(keystroke.key.as_str()) { |
| 219 | Some(c) => make_nsstring(String::from(*c)), |
| 220 | None => make_nsstring(&keystroke.key), |
| 221 | }; |
| 222 | |
| 223 | for (is_set, flag) in [ |
| 224 | (keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask), |
| 225 | (keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask), |
| 226 | (keystroke.shift, NSEventModifierFlags::NSShiftKeyMask), |
| 227 | (keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask), |
| 228 | ] { |
| 229 | if is_set { |
| 230 | flags |= flag |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | (key_equivalent, flags) |
| 235 | } |
| 236 | |
| 237 | // Apply any differences between the two states to the menu item. |
| 238 | unsafe fn apply_changes(changes: MenuItemPropertyChanges, item: id) { |
| 239 | // Wrap in a local autorelease pool: AppKit invokes `warp_menu_item_needs_update` |
| 240 | // on every menu validation (per menu open and per keystroke for shortcut matching), |
| 241 | // so this is a hot path. A local pool bounds peak memory for the NSString temporaries |
| 242 | // created here (item title, key equivalent) without relying on the outer AppKit pool. |
| 243 | let pool = NSAutoreleasePool::new(nil); |
| 244 | if let Some(name) = changes.name { |
| 245 | let _: () = msg_send![item, setTitle: make_nsstring(name)]; |
| 246 | } |
| 247 | if let Some(keystroke) = changes.keystroke { |
| 248 | let (key_equivalent, modifiers) = resolve_key_equivalent(keystroke.as_ref()); |
| 249 | let _: () = msg_send![item, setKeyEquivalent: key_equivalent]; |
| 250 | let _: () = msg_send![item, setKeyEquivalentModifierMask: modifiers]; |
| 251 | } |
| 252 | if let Some(disabled) = changes.disabled { |
| 253 | let enabled = if disabled { NO } else { YES }; |
| 254 | let _: () = msg_send![item, setEnabled: enabled]; |
| 255 | } |
| 256 | if let Some(checked) = changes.checked { |
| 257 | // NSControlStateValue has Off as 0, On as 1, Mixed as -1. |
| 258 | let control_state: NSInteger = i64::from(checked); |
| 259 | let _: () = msg_send![item, setState: control_state]; |
| 260 | } |
| 261 | if let Some(submenu) = changes.submenu { |
| 262 | let nsmenu = submenu |
| 263 | .map(|menu_items| make_submenu(menu_items)) |
| 264 | .unwrap_or(nil); |
| 265 | set_menu_item_submenu(item, nsmenu); |
| 266 | } |
| 267 | pool.drain(); |
| 268 | } |
| 269 | |
| 270 | unsafe fn make_submenu(menu_items: Vec<MenuItem>) -> id { |
| 271 | let nsmenu = make_delegated_menu(make_nsstring("")); |
| 272 | for menu_item in menu_items { |
| 273 | nsmenu.addItem_(make_menu_item(menu_item)); |
| 274 | } |
| 275 | nsmenu |
| 276 | } |
| 277 | |
| 278 | unsafe fn make_menu_item(menu_item: MenuItem) -> id { |
| 279 | match menu_item { |
| 280 | MenuItem::Custom(custom_menu_item) => { |
| 281 | let props = custom_menu_item.properties; |
| 282 | let data = Rc::new(MenuItemData { |
| 283 | props: RefCell::new(props.clone()), |
| 284 | triggered: custom_menu_item.callback, |
| 285 | update: custom_menu_item.updater, |
| 286 | }); |
| 287 | |
| 288 | let nsmenu_item = make_warp_custom_menu_item(MenuItemData::into_context(data)); |
| 289 | |
| 290 | // Set initial properties for the item. |
| 291 | apply_changes( |
| 292 | MenuItemPropertyChanges::for_new_item(props, custom_menu_item.submenu), |
| 293 | nsmenu_item, |
| 294 | ); |
| 295 | |
| 296 | nsmenu_item |
| 297 | } |
| 298 | MenuItem::Standard(standard_action) => { |
| 299 | let properties = resolve_standard_action(standard_action); |
| 300 | let nsmenu_item = NSMenuItem::alloc(nil) |
| 301 | .initWithTitle_action_keyEquivalent_( |
| 302 | make_nsstring(properties.title), |
| 303 | selector(properties.action), |
| 304 | make_nsstring(properties.shortcut), |
| 305 | ) |
| 306 | .autorelease(); |
| 307 | nsmenu_item.setKeyEquivalentModifierMask_(properties.modifiers); |
| 308 | let _: id = msg_send![nsmenu_item, setTag: standard_action as libc::c_long]; |
| 309 | nsmenu_item |
| 310 | } |
| 311 | MenuItem::Separator => NSMenuItem::separatorItem(nil), |
| 312 | MenuItem::Services => make_services_menu_item(), |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | /// \return an autoreleased NSMenuItem with a submenu represented by \p menu. |
| 317 | // This supports creating the top-level menu bar. |
| 318 | unsafe fn make_top_level_menu_item(menu: Menu) -> id { |
| 319 | let nsmenu = make_delegated_menu(make_nsstring(&menu.title)); |
| 320 | |
| 321 | if menu.is_window_menu() { |
| 322 | // `setWindowsMenu` gives us all the default window menu items like |
| 323 | // 'Enter Full Screen' and 'Tile Window to Left of Screen'. |
| 324 | let () = msg_send![NSApp(), setWindowsMenu: nsmenu]; |
| 325 | } |
| 326 | |
| 327 | for menu_item in menu.menu_items { |
| 328 | nsmenu.addItem_(make_menu_item(menu_item)); |
| 329 | } |
| 330 | |
| 331 | let menuitem = NSMenuItem::alloc(nil).init().autorelease(); |
| 332 | menuitem.setSubmenu_(nsmenu); |
| 333 | menuitem |
| 334 | } |
| 335 | |
| 336 | /// \return an autoreleased NSMenu representing the given menu bar. |
| 337 | pub unsafe fn make_main_menu(menubar: MenuBar) -> id { |
| 338 | let main_menu = NSMenu::alloc(nil).init().autorelease(); |
| 339 | for menu in menubar.menus { |
| 340 | main_menu.addItem_(make_top_level_menu_item(menu)); |
| 341 | } |
| 342 | main_menu |
| 343 | } |
| 344 | |
| 345 | /// \return an autoreleased NSMenu representing the given dock menu. |
| 346 | pub unsafe fn make_dock_menu(menu: Menu) -> id { |
| 347 | let dock_menu = NSMenu::alloc(nil).init().autorelease(); |
| 348 | for item in menu.menu_items { |
| 349 | dock_menu.addItem_(make_menu_item(item)); |
| 350 | } |
| 351 | dock_menu |
| 352 | } |
| 353 | |
| 354 | #[cfg(test)] |
| 355 | #[path = "menus_tests.rs"] |
| 356 | mod tests; |
| 357 |