use crate::DesktopMenu;
use objc2::rc::Retained;
use objc2::runtime::Sel;
use objc2::{define_class, msg_send, sel, DefinedClass, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{NSEventModifierFlags, NSMenu, NSMenuItem, NSWindow};
use objc2_foundation::{NSObject, NSObjectProtocol, NSString};
#[must_use = "retain the native menu owner until the window session ends"]
pub(super) struct NativeMenu {
menu: Retained<NSMenu>,
_target: Option<Retained<DesktopMenuTarget>>,
}
impl NativeMenu {
pub(super) fn menu(&self) -> &NSMenu {
&self.menu
}
}
pub(super) fn build_main_menu<F>(
mtm: MainThreadMarker,
window: &NSWindow,
menus: &[DesktopMenu],
dispatch: F,
) -> NativeMenu
where
F: Fn(&str) + 'static,
{
let bar = NSMenu::new(mtm);
if menus.is_empty() {
bar.addItem(&submenu_item(mtm, &default_app_menu(mtm)));
bar.addItem(&submenu_item(mtm, &default_edit_menu(mtm)));
bar.addItem(&submenu_item(mtm, &default_window_menu(mtm, window)));
return NativeMenu {
menu: bar,
_target: None,
};
}
let target = DesktopMenuTarget::new(mtm, Box::new(dispatch));
for menu in menus {
bar.addItem(&submenu_item(mtm, &build_menu(mtm, menu, &target)));
}
NativeMenu {
menu: bar,
_target: Some(target),
}
}
fn submenu_item(mtm: MainThreadMarker, submenu: &NSMenu) -> Retained<NSMenuItem> {
let item = NSMenuItem::new(mtm);
item.setSubmenu(Some(submenu));
item
}
fn build_menu(
mtm: MainThreadMarker,
menu: &DesktopMenu,
target: &Retained<DesktopMenuTarget>,
) -> Retained<NSMenu> {
let native = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str(&menu.label));
for item in &menu.items {
let native_item = NSMenuItem::new(mtm);
native_item.setTitle(&NSString::from_str(&item.label));
if let Some(accelerator) = &item.accelerator {
apply_accelerator(&native_item, accelerator);
}
if let Some(command) = &item.command {
let index = target.push_command(command.clone());
native_item.setTag(index);
unsafe {
native_item.setTarget(Some(target));
native_item.setAction(Some(sel!(performDesktopMenuCommand:)));
}
}
native.addItem(&native_item);
}
native
}
fn default_app_menu(mtm: MainThreadMarker) -> Retained<NSMenu> {
let menu = NSMenu::new(mtm);
let app_name = process_name();
menu.addItem(&standard_item(
mtm,
&format!("About {app_name}"),
sel!(orderFrontStandardAboutPanel:),
"",
));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&standard_item(
mtm,
&format!("Hide {app_name}"),
sel!(hide:),
"h",
));
menu.addItem(&standard_item(
mtm,
"Hide Others",
sel!(hideOtherApplications:),
"h",
));
menu.addItem(&standard_item(
mtm,
"Show All",
sel!(unhideAllApplications:),
"",
));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&standard_item(
mtm,
&format!("Quit {app_name}"),
sel!(terminate:),
"q",
));
menu
}
fn default_edit_menu(mtm: MainThreadMarker) -> Retained<NSMenu> {
let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str("Edit"));
menu.addItem(&standard_item(mtm, "Undo", sel!(undo:), "z"));
menu.addItem(&standard_item(mtm, "Redo", sel!(redo:), "Z"));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&standard_item(mtm, "Cut", sel!(cut:), "x"));
menu.addItem(&standard_item(mtm, "Copy", sel!(copy:), "c"));
menu.addItem(&standard_item(mtm, "Paste", sel!(paste:), "v"));
menu.addItem(&standard_item(mtm, "Select All", sel!(selectAll:), "a"));
menu
}
fn default_window_menu(mtm: MainThreadMarker, window: &NSWindow) -> Retained<NSMenu> {
let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str("Window"));
menu.addItem(&window_item(
mtm,
window,
"Close Window",
sel!(performClose:),
"w",
));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&window_item(
mtm,
window,
"Minimize",
sel!(performMiniaturize:),
"m",
));
menu.addItem(&window_item(mtm, window, "Zoom", sel!(performZoom:), ""));
let (title, action, key, modifiers) = fullscreen_command();
let fullscreen = window_item(mtm, window, title, action, key);
fullscreen.setKeyEquivalentModifierMask(modifiers);
menu.addItem(&fullscreen);
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&standard_item(
mtm,
"Bring All to Front",
sel!(arrangeInFront:),
"",
));
menu
}
fn fullscreen_command() -> (&'static str, Sel, &'static str, NSEventModifierFlags) {
(
"Toggle Full Screen",
sel!(toggleFullScreen:),
"f",
NSEventModifierFlags::Control | NSEventModifierFlags::Command,
)
}
fn window_item(
mtm: MainThreadMarker,
window: &NSWindow,
title: &str,
action: Sel,
key_equivalent: &str,
) -> Retained<NSMenuItem> {
let item = standard_item(mtm, title, action, key_equivalent);
unsafe { item.setTarget(Some(window)) };
item
}
fn standard_item(
mtm: MainThreadMarker,
title: &str,
action: Sel,
key_equivalent: &str,
) -> Retained<NSMenuItem> {
unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(title),
Some(action),
&NSString::from_str(key_equivalent),
)
}
}
fn apply_accelerator(item: &NSMenuItem, accelerator: &str) {
let mut mask = NSEventModifierFlags::empty();
let mut key = String::new();
for part in accelerator.split('+') {
match part.trim().to_ascii_lowercase().as_str() {
"cmd" | "cmdorctrl" | "command" | "super" => mask |= NSEventModifierFlags::Command,
"ctrl" | "control" => mask |= NSEventModifierFlags::Control,
"alt" | "option" => mask |= NSEventModifierFlags::Option,
"shift" => mask |= NSEventModifierFlags::Shift,
other => key = other.to_string(),
}
}
if key.is_empty() {
return;
}
item.setKeyEquivalent(&NSString::from_str(&key));
item.setKeyEquivalentModifierMask(mask);
}
fn process_name() -> String {
std::env::current_exe()
.ok()
.and_then(|path| path.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "App".to_string())
}
struct MenuTargetIvars {
dispatch: Box<dyn Fn(&str)>,
commands: std::cell::RefCell<Vec<String>>,
}
define_class!(
#[unsafe(super = NSObject)]
#[thread_kind = MainThreadOnly]
#[ivars = MenuTargetIvars]
struct DesktopMenuTarget;
unsafe impl NSObjectProtocol for DesktopMenuTarget {}
impl DesktopMenuTarget {
#[unsafe(method(performDesktopMenuCommand:))]
fn perform_desktop_menu_command(&self, sender: &NSMenuItem) {
let ivars = self.ivars();
let tag = usize::try_from(sender.tag()).unwrap_or(usize::MAX);
let Some(command) = ivars.commands.borrow().get(tag).cloned() else {
return;
};
let script = menu_command_script(&command);
(ivars.dispatch)(&script);
}
}
);
impl DesktopMenuTarget {
fn new(mtm: MainThreadMarker, dispatch: Box<dyn Fn(&str)>) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(MenuTargetIvars {
dispatch,
commands: std::cell::RefCell::new(Vec::new()),
});
unsafe { msg_send![super(this), init] }
}
fn push_command(&self, command: String) -> objc2::ffi::NSInteger {
let mut commands = self.ivars().commands.borrow_mut();
commands.push(command);
objc2::ffi::NSInteger::try_from(commands.len() - 1).unwrap_or(0)
}
}
#[must_use]
fn menu_command_script(command: &str) -> String {
let id = serde_json::to_string(command).unwrap_or_else(|_| "\"\"".to_string());
format!("window.dispatchEvent(new CustomEvent(\"webui:menu-command\",{{detail:{{id:{id}}}}}));")
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use objc2::sel;
use objc2_app_kit::NSEventModifierFlags;
#[test]
fn command_script_embeds_id_as_json() {
let script = super::menu_command_script("open-preferences");
assert!(script.contains("webui:menu-command"));
assert!(script.contains("id:\"open-preferences\""));
}
#[test]
fn default_window_fullscreen_uses_native_toggle_and_standard_shortcut() {
let (title, action, key, modifiers) = super::fullscreen_command();
assert_eq!(title, "Toggle Full Screen");
assert_eq!(action, sel!(toggleFullScreen:));
assert_eq!(key, "f");
assert_eq!(
modifiers,
NSEventModifierFlags::Command | NSEventModifierFlags::Control
);
}
}