357 lines
14 KiB
Rust
357 lines
14 KiB
Rust
use cocoa::appkit::{NSApp, NSEventModifierFlags, NSMenu, NSMenuItem};
|
|
use cocoa::base::selector;
|
|
use cocoa::{
|
|
appkit::{
|
|
NSDownArrowFunctionKey, NSEndFunctionKey, NSF10FunctionKey, NSF11FunctionKey,
|
|
NSF12FunctionKey, NSF13FunctionKey, NSF14FunctionKey, NSF15FunctionKey, NSF16FunctionKey,
|
|
NSF17FunctionKey, NSF18FunctionKey, NSF19FunctionKey, NSF1FunctionKey, NSF20FunctionKey,
|
|
NSF2FunctionKey, NSF3FunctionKey, NSF4FunctionKey, NSF5FunctionKey, NSF6FunctionKey,
|
|
NSF7FunctionKey, NSF8FunctionKey, NSF9FunctionKey, NSHomeFunctionKey, NSInsertFunctionKey,
|
|
NSLeftArrowFunctionKey, NSPageDownFunctionKey, NSPageUpFunctionKey,
|
|
NSRightArrowFunctionKey, NSUpArrowFunctionKey,
|
|
},
|
|
base::{id, nil},
|
|
foundation::{NSArray, NSAutoreleasePool, NSInteger},
|
|
};
|
|
use lazy_static::lazy_static;
|
|
use objc::runtime::{NO, YES};
|
|
use objc::{msg_send, sel, sel_impl};
|
|
use std::{boxed::Box, cell::RefCell, collections::HashMap, ffi::c_void, rc::Rc};
|
|
use warpui_core::actions::StandardAction;
|
|
use warpui_core::keymap::Keystroke;
|
|
use warpui_core::platform::menu::{
|
|
ItemTriggeredCallback, Menu, MenuBar, MenuItem, MenuItemProperties, MenuItemPropertyChanges,
|
|
UpdateMenuItemCallback,
|
|
};
|
|
|
|
use super::app::callback_dispatcher;
|
|
use super::make_nsstring;
|
|
|
|
lazy_static! {
|
|
/// A mac-menu-specific map of key names to special characters used for the keyboard shortcuts
|
|
/// in the mac menus
|
|
static ref MENU_KEY_EQUIVALENTS: HashMap<&'static str, char> = {
|
|
fn to_char(key: u16) -> char {
|
|
char::from_u32(key.into()).unwrap()
|
|
}
|
|
|
|
HashMap::from([
|
|
("up", to_char(NSUpArrowFunctionKey)),
|
|
("down", to_char(NSDownArrowFunctionKey)),
|
|
("left", to_char(NSLeftArrowFunctionKey)),
|
|
("right", to_char(NSRightArrowFunctionKey)),
|
|
("home", to_char(NSHomeFunctionKey)),
|
|
("end", to_char(NSEndFunctionKey)),
|
|
("pageup", to_char(NSPageUpFunctionKey)),
|
|
("pagedown", to_char(NSPageDownFunctionKey)),
|
|
("enter", '\n'),
|
|
("tab", '\t'),
|
|
("insert", to_char(NSInsertFunctionKey)),
|
|
("f1", to_char(NSF1FunctionKey)),
|
|
("f2", to_char(NSF2FunctionKey)),
|
|
("f3", to_char(NSF3FunctionKey)),
|
|
("f4", to_char(NSF4FunctionKey)),
|
|
("f5", to_char(NSF5FunctionKey)),
|
|
("f6", to_char(NSF6FunctionKey)),
|
|
("f7", to_char(NSF7FunctionKey)),
|
|
("f8", to_char(NSF8FunctionKey)),
|
|
("f9", to_char(NSF9FunctionKey)),
|
|
("f10", to_char(NSF10FunctionKey)),
|
|
("f11", to_char(NSF11FunctionKey)),
|
|
("f12", to_char(NSF12FunctionKey)),
|
|
("f13", to_char(NSF13FunctionKey)),
|
|
("f14", to_char(NSF14FunctionKey)),
|
|
("f15", to_char(NSF15FunctionKey)),
|
|
("f16", to_char(NSF16FunctionKey)),
|
|
("f17", to_char(NSF17FunctionKey)),
|
|
("f18", to_char(NSF18FunctionKey)),
|
|
("f19", to_char(NSF19FunctionKey)),
|
|
("f20", to_char(NSF20FunctionKey)),
|
|
// The following values are the inverse of `ui/src/platform/mac/event.rs` mappings
|
|
("numpadenter", to_char(0x03)),
|
|
("escape", to_char(0x1b)),
|
|
// Note: Backspace and Delete have different characters for the menu key equivalents
|
|
// than they send when they are pressed. See the discussion in the Apple docs:
|
|
// https://developer.apple.com/documentation/appkit/nsmenuitem/1514842-keyequivalent?language=objc
|
|
("backspace", to_char(0x08)),
|
|
("delete", to_char(0x7F)),
|
|
])
|
|
};
|
|
}
|
|
|
|
/// Data associated with a custom NSMenuItem.
|
|
struct MenuItemData {
|
|
/// Properties of the menu item.
|
|
/// These could be computed from the menu item but we trust AppKit does not change them.
|
|
props: RefCell<MenuItemProperties>,
|
|
|
|
/// Callback when the menu item is triggered by the user.
|
|
triggered: ItemTriggeredCallback,
|
|
|
|
/// Callback when the menu item needs updating.
|
|
update: UpdateMenuItemCallback,
|
|
}
|
|
|
|
impl MenuItemData {
|
|
/// Convert self to a Cocoa context pointer, including the refcount.
|
|
/// This should be balanced by consume_cocoa_context.
|
|
fn into_context(self: Rc<MenuItemData>) -> *mut c_void {
|
|
Box::into_raw(Box::new(self)) as *mut c_void
|
|
}
|
|
|
|
/// Read out from the Cocoa context pointer, without consuming its refcount.
|
|
fn read_context(ctx: *const c_void) -> Rc<MenuItemData> {
|
|
unsafe {
|
|
let ptr = &*(ctx as *const Rc<MenuItemData>);
|
|
ptr.clone()
|
|
}
|
|
}
|
|
|
|
/// Balances a call from to_cocoa_context.
|
|
fn consume_context(ctx: *mut c_void) {
|
|
unsafe { std::mem::drop(Box::from_raw(ctx as *mut Rc<MenuItemData>)) }
|
|
}
|
|
}
|
|
|
|
/// We hand Cocoa a void* which is really an unwrapped Box<Rc<MenuItemData>>.
|
|
/// The NSMenuItem logically holds a reference count on this Rc, which is balanced in our dealloc callback below.
|
|
/// The following functions are invoked from Cocoa.
|
|
#[no_mangle]
|
|
extern "C-unwind" fn warp_menu_item_needs_update(item: id, ctx: *mut c_void) {
|
|
let ctx = MenuItemData::read_context(ctx);
|
|
let props: MenuItemProperties = ctx.props.borrow().clone();
|
|
let func = &ctx.update;
|
|
|
|
let mut updated_properties = callback_dispatcher().update_menu_item(|ctx| func(&props, ctx));
|
|
|
|
// Always re-apply the disabled state even when the updater has no opinion.
|
|
// AppKit's modal sessions (e.g. [NSAlert runModal]) can externally disable
|
|
// menu items, and items whose updaters return `disabled: None` would never
|
|
// call setEnabled: to restore the correct state. On macOS with the quake
|
|
// mode (non-activating panel) window, this results in permanently disabled
|
|
// items after a modal is dismissed. Default to enabled — updaters that want
|
|
// an item disabled must say so explicitly.
|
|
if updated_properties.disabled.is_none() {
|
|
updated_properties.disabled = Some(false);
|
|
}
|
|
|
|
// Update any changed properties.
|
|
ctx.props.borrow_mut().apply(&updated_properties);
|
|
unsafe { apply_changes(updated_properties, item) };
|
|
}
|
|
|
|
#[no_mangle]
|
|
extern "C-unwind" fn warp_menu_item_triggered(_item: id, ctx: *mut c_void) {
|
|
let func = &MenuItemData::read_context(ctx).triggered;
|
|
callback_dispatcher().menu_item_triggered(func);
|
|
}
|
|
|
|
#[no_mangle]
|
|
extern "C-unwind" fn warp_menu_item_deallocated(ctx: *mut c_void) {
|
|
MenuItemData::consume_context(ctx)
|
|
}
|
|
|
|
// Declarations of functions implemented in ObjC files.
|
|
// These signatures must be manually synced - there's no type checking here.
|
|
extern "C" {
|
|
fn make_delegated_menu(title: id) -> id;
|
|
fn make_warp_custom_menu_item(ctx: *mut c_void) -> id;
|
|
fn set_menu_item_submenu(item: id, submenu: id);
|
|
fn make_services_menu_item() -> id;
|
|
}
|
|
|
|
struct StandardMenuItemProperties {
|
|
title: &'static str, // menu item title
|
|
action: &'static str, // the selector name
|
|
shortcut: &'static str, // the key equivalent string, or empty for none
|
|
modifiers: NSEventModifierFlags,
|
|
}
|
|
|
|
// Get properties from a standard action.
|
|
fn resolve_standard_action(action: StandardAction) -> StandardMenuItemProperties {
|
|
let cmd = NSEventModifierFlags::NSCommandKeyMask;
|
|
let option = NSEventModifierFlags::NSAlternateKeyMask;
|
|
let ctrl = NSEventModifierFlags::NSControlKeyMask;
|
|
let none = NSEventModifierFlags::empty();
|
|
|
|
fn make(
|
|
title: &'static str,
|
|
action: &'static str,
|
|
modifiers: NSEventModifierFlags,
|
|
shortcut: &'static str,
|
|
) -> StandardMenuItemProperties {
|
|
StandardMenuItemProperties {
|
|
title,
|
|
action,
|
|
shortcut,
|
|
modifiers,
|
|
}
|
|
}
|
|
|
|
match action {
|
|
StandardAction::Close => make("Close Window", "performClose:", none, ""),
|
|
StandardAction::Quit => make("Quit Warp", "terminate:", cmd, "q"),
|
|
StandardAction::Hide => make("Hide Warp", "hide:", cmd, "h"),
|
|
StandardAction::HideOtherApps => {
|
|
make("Hide Others", "hideOtherApplications:", cmd | option, "h")
|
|
}
|
|
StandardAction::ShowAllApps => make("Show All", "unhideAllApplications:", none, ""),
|
|
StandardAction::Minimize => make("Minimize", "performMiniaturize:", cmd, "m"),
|
|
StandardAction::Zoom => make("Zoom", "performZoom:", none, ""),
|
|
StandardAction::BringAllToFront => make("Bring All to Front", "arrangeInFront:", none, ""),
|
|
StandardAction::ToggleFullScreen => {
|
|
make("ToggleFullScreen", "toggleFullScreen:", cmd | ctrl, "f")
|
|
}
|
|
StandardAction::Paste => make("Paste", "paste:", none, ""),
|
|
}
|
|
}
|
|
|
|
/// Determine the key equivalent for the given keystroke
|
|
fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (id, NSEventModifierFlags) {
|
|
let mut flags = NSEventModifierFlags::empty();
|
|
|
|
let keystroke = match keystroke {
|
|
Some(value) => value,
|
|
None => return (make_nsstring(""), flags),
|
|
};
|
|
|
|
let key_equivalent = match MENU_KEY_EQUIVALENTS.get(keystroke.key.as_str()) {
|
|
Some(c) => make_nsstring(String::from(*c)),
|
|
None => make_nsstring(&keystroke.key),
|
|
};
|
|
|
|
for (is_set, flag) in [
|
|
(keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask),
|
|
(keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask),
|
|
(keystroke.shift, NSEventModifierFlags::NSShiftKeyMask),
|
|
(keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask),
|
|
] {
|
|
if is_set {
|
|
flags |= flag
|
|
}
|
|
}
|
|
|
|
(key_equivalent, flags)
|
|
}
|
|
|
|
// Apply any differences between the two states to the menu item.
|
|
unsafe fn apply_changes(changes: MenuItemPropertyChanges, item: id) {
|
|
// Wrap in a local autorelease pool: AppKit invokes `warp_menu_item_needs_update`
|
|
// on every menu validation (per menu open and per keystroke for shortcut matching),
|
|
// so this is a hot path. A local pool bounds peak memory for the NSString temporaries
|
|
// created here (item title, key equivalent) without relying on the outer AppKit pool.
|
|
let pool = NSAutoreleasePool::new(nil);
|
|
if let Some(name) = changes.name {
|
|
let _: () = msg_send![item, setTitle: make_nsstring(name)];
|
|
}
|
|
if let Some(keystroke) = changes.keystroke {
|
|
let (key_equivalent, modifiers) = resolve_key_equivalent(keystroke.as_ref());
|
|
let _: () = msg_send![item, setKeyEquivalent: key_equivalent];
|
|
let _: () = msg_send![item, setKeyEquivalentModifierMask: modifiers];
|
|
}
|
|
if let Some(disabled) = changes.disabled {
|
|
let enabled = if disabled { NO } else { YES };
|
|
let _: () = msg_send![item, setEnabled: enabled];
|
|
}
|
|
if let Some(checked) = changes.checked {
|
|
// NSControlStateValue has Off as 0, On as 1, Mixed as -1.
|
|
let control_state: NSInteger = i64::from(checked);
|
|
let _: () = msg_send![item, setState: control_state];
|
|
}
|
|
if let Some(submenu) = changes.submenu {
|
|
let nsmenu = submenu
|
|
.map(|menu_items| make_submenu(menu_items))
|
|
.unwrap_or(nil);
|
|
set_menu_item_submenu(item, nsmenu);
|
|
}
|
|
pool.drain();
|
|
}
|
|
|
|
unsafe fn make_submenu(menu_items: Vec<MenuItem>) -> id {
|
|
let nsmenu = make_delegated_menu(make_nsstring(""));
|
|
for menu_item in menu_items {
|
|
nsmenu.addItem_(make_menu_item(menu_item));
|
|
}
|
|
nsmenu
|
|
}
|
|
|
|
unsafe fn make_menu_item(menu_item: MenuItem) -> id {
|
|
match menu_item {
|
|
MenuItem::Custom(custom_menu_item) => {
|
|
let props = custom_menu_item.properties;
|
|
let data = Rc::new(MenuItemData {
|
|
props: RefCell::new(props.clone()),
|
|
triggered: custom_menu_item.callback,
|
|
update: custom_menu_item.updater,
|
|
});
|
|
|
|
let nsmenu_item = make_warp_custom_menu_item(MenuItemData::into_context(data));
|
|
|
|
// Set initial properties for the item.
|
|
apply_changes(
|
|
MenuItemPropertyChanges::for_new_item(props, custom_menu_item.submenu),
|
|
nsmenu_item,
|
|
);
|
|
|
|
nsmenu_item
|
|
}
|
|
MenuItem::Standard(standard_action) => {
|
|
let properties = resolve_standard_action(standard_action);
|
|
let nsmenu_item = NSMenuItem::alloc(nil)
|
|
.initWithTitle_action_keyEquivalent_(
|
|
make_nsstring(properties.title),
|
|
selector(properties.action),
|
|
make_nsstring(properties.shortcut),
|
|
)
|
|
.autorelease();
|
|
nsmenu_item.setKeyEquivalentModifierMask_(properties.modifiers);
|
|
let _: id = msg_send![nsmenu_item, setTag: standard_action as libc::c_long];
|
|
nsmenu_item
|
|
}
|
|
MenuItem::Separator => NSMenuItem::separatorItem(nil),
|
|
MenuItem::Services => make_services_menu_item(),
|
|
}
|
|
}
|
|
|
|
/// \return an autoreleased NSMenuItem with a submenu represented by \p menu.
|
|
// This supports creating the top-level menu bar.
|
|
unsafe fn make_top_level_menu_item(menu: Menu) -> id {
|
|
let nsmenu = make_delegated_menu(make_nsstring(&menu.title));
|
|
|
|
if menu.is_window_menu() {
|
|
// `setWindowsMenu` gives us all the default window menu items like
|
|
// 'Enter Full Screen' and 'Tile Window to Left of Screen'.
|
|
let () = msg_send![NSApp(), setWindowsMenu: nsmenu];
|
|
}
|
|
|
|
for menu_item in menu.menu_items {
|
|
nsmenu.addItem_(make_menu_item(menu_item));
|
|
}
|
|
|
|
let menuitem = NSMenuItem::alloc(nil).init().autorelease();
|
|
menuitem.setSubmenu_(nsmenu);
|
|
menuitem
|
|
}
|
|
|
|
/// \return an autoreleased NSMenu representing the given menu bar.
|
|
pub unsafe fn make_main_menu(menubar: MenuBar) -> id {
|
|
let main_menu = NSMenu::alloc(nil).init().autorelease();
|
|
for menu in menubar.menus {
|
|
main_menu.addItem_(make_top_level_menu_item(menu));
|
|
}
|
|
main_menu
|
|
}
|
|
|
|
/// \return an autoreleased NSMenu representing the given dock menu.
|
|
pub unsafe fn make_dock_menu(menu: Menu) -> id {
|
|
let dock_menu = NSMenu::alloc(nil).init().autorelease();
|
|
for item in menu.menu_items {
|
|
dock_menu.addItem_(make_menu_item(item));
|
|
}
|
|
dock_menu
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "menus_tests.rs"]
|
|
mod tests;
|