use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, Sel};
use objc2::{define_class, msg_send, sel, DefinedClass, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSControlStateValueOff, NSControlStateValueOn, NSEventModifierFlags, NSMenu,
NSMenuItem,
};
use objc2_foundation::{MainThreadMarker, NSProcessInfo, NSString};
use crate::accelerator::{parse_key_binding, Accelerator, KeyBinding, ParsedBinding};
use crate::event::UiEvent;
use crate::primitives::context_menu::ContextMenuItem;
use crate::primitives::menu_bar::{MenuBar, MenuBarItem};
use crate::types::WidgetId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MenuKind {
Bar,
Context,
}
pub(crate) struct QuadraMenuTargetIvars {
tag_to_id: RefCell<HashMap<isize, WidgetId>>,
events: Rc<RefCell<VecDeque<UiEvent>>>,
kind: MenuKind,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = QuadraMenuTargetIvars]
pub(crate) struct QuadraMenuTarget;
unsafe impl NSObjectProtocol for QuadraMenuTarget {}
impl QuadraMenuTarget {
#[unsafe(method(quadraMenuAction:))]
fn quadra_menu_action(&self, sender: &NSMenuItem) {
let tag = sender.tag();
let id = self.ivars().tag_to_id.borrow().get(&tag).cloned();
if let Some(id) = id {
let event = match self.ivars().kind {
MenuKind::Bar => UiEvent::MenuActivated(id),
MenuKind::Context => UiEvent::ContextMenuItemActivated(id),
};
self.ivars().events.borrow_mut().push_back(event);
let mtm = MainThreadMarker::from(self);
let app = NSApplication::sharedApplication(mtm);
if let Some(window) = app.keyWindow() {
if let Some(view) = window.contentView() {
view.setNeedsDisplay(true);
}
}
}
}
}
);
impl QuadraMenuTarget {
pub(crate) fn new(
mtm: MainThreadMarker,
events: Rc<RefCell<VecDeque<UiEvent>>>,
kind: MenuKind,
) -> Retained<Self> {
let this = mtm.alloc::<Self>();
let this = this.set_ivars(QuadraMenuTargetIvars {
tag_to_id: RefCell::new(HashMap::new()),
events,
kind,
});
unsafe { msg_send![super(this), init] }
}
#[cfg(test)]
pub(crate) fn simulate_activation(&self, tag: isize) {
let id = self.ivars().tag_to_id.borrow().get(&tag).cloned();
if let Some(id) = id {
let event = match self.ivars().kind {
MenuKind::Bar => UiEvent::MenuActivated(id),
MenuKind::Context => UiEvent::ContextMenuItemActivated(id),
};
self.ivars().events.borrow_mut().push_back(event);
}
}
#[cfg(test)]
pub(crate) fn registered_id(&self, tag: isize) -> Option<WidgetId> {
self.ivars().tag_to_id.borrow().get(&tag).cloned()
}
}
pub(crate) fn install_menu_bar(
mtm: MainThreadMarker,
bar: &MenuBar,
events: Rc<RefCell<VecDeque<UiEvent>>>,
) -> Retained<QuadraMenuTarget> {
let target = QuadraMenuTarget::new(mtm, events, MenuKind::Bar);
let main_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(""),
]
};
let app_name = NSProcessInfo::processInfo().processName().to_string();
append_app_menu(mtm, &main_menu, &app_name);
let mut next_tag: isize = 1;
for top in &bar.items {
append_top_level_menu(mtm, &main_menu, &target, top, &mut next_tag);
}
append_full_screen_item(mtm, &main_menu);
let ns_app = NSApplication::sharedApplication(mtm);
ns_app.setMainMenu(Some(&main_menu));
target
}
fn append_full_screen_item(mtm: MainThreadMarker, main_menu: &NSMenu) {
let target_menu = find_top_level_menu(main_menu, &["View", "Window"]).unwrap_or_else(|| {
let view_item: Retained<NSMenuItem> = NSMenuItem::new(mtm);
view_item.setTitle(&NSString::from_str("View"));
main_menu.addItem(&view_item);
let view_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str("View"),
]
};
view_item.setSubmenu(Some(&view_menu));
view_menu
});
if target_menu.numberOfItems() > 0 {
target_menu.addItem(&NSMenuItem::separatorItem(mtm));
}
add_stock_item(
mtm,
&target_menu,
"Enter Full Screen",
sel!(toggleFullScreen:),
"f",
NSEventModifierFlags::Command | NSEventModifierFlags::Control,
);
}
fn find_top_level_menu(main_menu: &NSMenu, titles: &[&str]) -> Option<Retained<NSMenu>> {
for title in titles {
for item in main_menu.itemArray().iter() {
let Some(submenu) = item.submenu() else {
continue;
};
if submenu.title().to_string().eq_ignore_ascii_case(title) {
return Some(submenu);
}
}
}
None
}
fn append_app_menu(mtm: MainThreadMarker, main_menu: &NSMenu, app_name: &str) {
let app_item: Retained<NSMenuItem> = NSMenuItem::new(mtm);
main_menu.addItem(&app_item);
let app_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(app_name),
]
};
app_item.setSubmenu(Some(&app_menu));
let cmd = NSEventModifierFlags::Command;
let cmd_opt = NSEventModifierFlags::Command | NSEventModifierFlags::Option;
add_stock_item(
mtm,
&app_menu,
&format!("About {app_name}"),
sel!(orderFrontStandardAboutPanel:),
"",
NSEventModifierFlags(0),
);
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
add_stock_item(
mtm,
&app_menu,
&format!("Hide {app_name}"),
sel!(hide:),
"h",
cmd,
);
add_stock_item(
mtm,
&app_menu,
"Hide Others",
sel!(hideOtherApplications:),
"h",
cmd_opt,
);
add_stock_item(
mtm,
&app_menu,
"Show All",
sel!(unhideAllApplications:),
"",
NSEventModifierFlags(0),
);
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
add_stock_item(
mtm,
&app_menu,
&format!("Quit {app_name}"),
sel!(terminate:),
"q",
cmd,
);
}
fn add_stock_item(
mtm: MainThreadMarker,
menu: &NSMenu,
title: &str,
action: Sel,
key_equivalent: &str,
modifiers: NSEventModifierFlags,
) {
let title_ns = NSString::from_str(title);
let key_ns = NSString::from_str(key_equivalent);
let item: Retained<NSMenuItem> = unsafe {
msg_send![
mtm.alloc::<NSMenuItem>(),
initWithTitle: &*title_ns,
action: Some(action),
keyEquivalent: &*key_ns,
]
};
item.setKeyEquivalentModifierMask(modifiers);
menu.addItem(&item);
}
fn append_top_level_menu(
mtm: MainThreadMarker,
main_menu: &NSMenu,
target: &QuadraMenuTarget,
top: &MenuBarItem,
next_tag: &mut isize,
) {
let title = strip_mnemonic(&top.label);
let container: Retained<NSMenuItem> = NSMenuItem::new(mtm);
container.setTitle(&NSString::from_str(&title));
container.setEnabled(!top.disabled);
main_menu.addItem(&container);
let submenu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(&title),
]
};
container.setSubmenu(Some(&submenu));
if let Some(items) = top.submenu.as_ref() {
for item in items {
append_menu_item(mtm, &submenu, target, item, next_tag);
}
}
}
fn append_menu_item(
mtm: MainThreadMarker,
menu: &NSMenu,
target: &QuadraMenuTarget,
item: &ContextMenuItem,
next_tag: &mut isize,
) {
if item.is_separator() {
menu.addItem(&NSMenuItem::separatorItem(mtm));
return;
}
let title: String = item.label.spans.iter().map(|s| s.text.as_str()).collect();
let ns_item: Retained<NSMenuItem> = NSMenuItem::new(mtm);
ns_item.setTitle(&NSString::from_str(&title));
ns_item.setEnabled(!item.disabled);
if let Some(true) = item.checked {
ns_item.setState(NSControlStateValueOn);
} else if let Some(false) = item.checked {
ns_item.setState(NSControlStateValueOff);
}
if let Some(nested) = item.submenu.as_ref() {
let child_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(&title),
]
};
for child in nested {
append_menu_item(mtm, &child_menu, target, child, next_tag);
}
ns_item.setSubmenu(Some(&child_menu));
menu.addItem(&ns_item);
return;
}
if let Some(ref id) = item.id {
let tag = *next_tag;
*next_tag += 1;
ns_item.setTag(tag);
target.ivars().tag_to_id.borrow_mut().insert(tag, *id);
unsafe { ns_item.setAction(Some(sel!(quadraMenuAction:))) };
let target_obj: &AnyObject = unsafe { &*(target as *const _ as *const AnyObject) };
unsafe { ns_item.setTarget(Some(target_obj)) };
if let Some(ref acc) = item.key_equivalent {
if let Some((key, mods)) = accelerator_to_ns(acc) {
ns_item.setKeyEquivalent(&NSString::from_str(&key));
ns_item.setKeyEquivalentModifierMask(mods);
}
}
}
menu.addItem(&ns_item);
}
fn strip_mnemonic(label: &str) -> String {
label.replace('&', "")
}
fn accelerator_to_ns(acc: &Accelerator) -> Option<(String, NSEventModifierFlags)> {
match &acc.binding {
KeyBinding::Save => Some(("s".into(), cmd())),
KeyBinding::Open => Some(("o".into(), cmd())),
KeyBinding::New => Some(("n".into(), cmd())),
KeyBinding::Close => Some(("w".into(), cmd())),
KeyBinding::Copy => Some(("c".into(), cmd())),
KeyBinding::Cut => Some(("x".into(), cmd())),
KeyBinding::Paste => Some(("v".into(), cmd())),
KeyBinding::Undo => Some(("z".into(), cmd())),
KeyBinding::Redo => Some(("z".into(), cmd() | shift())),
KeyBinding::SelectAll => Some(("a".into(), cmd())),
KeyBinding::Find => Some(("f".into(), cmd())),
KeyBinding::Replace => Some(("h".into(), cmd())),
KeyBinding::Quit => Some(("q".into(), cmd())),
KeyBinding::Literal(s) => parse_key_binding(s).and_then(parsed_to_ns),
}
}
fn parsed_to_ns(p: ParsedBinding) -> Option<(String, NSEventModifierFlags)> {
let key = if p.key.chars().count() == 1 {
p.key
} else {
return None;
};
let mut mask = NSEventModifierFlags(0);
if p.modifiers.ctrl {
mask |= NSEventModifierFlags::Control;
}
if p.modifiers.shift {
mask |= NSEventModifierFlags::Shift;
}
if p.modifiers.alt {
mask |= NSEventModifierFlags::Option;
}
if p.modifiers.cmd {
mask |= NSEventModifierFlags::Command;
}
Some((key, mask))
}
fn cmd() -> NSEventModifierFlags {
NSEventModifierFlags::Command
}
fn shift() -> NSEventModifierFlags {
NSEventModifierFlags::Shift
}
pub(crate) fn build_ns_menu(
mtm: MainThreadMarker,
items: &[ContextMenuItem],
target: &QuadraMenuTarget,
) -> Retained<NSMenu> {
let ns_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(""),
]
};
let mut next_tag: isize = 1;
for item in items {
append_menu_item(mtm, &ns_menu, target, item, &mut next_tag);
}
ns_menu
}
pub(crate) fn show_context_menu(
mtm: MainThreadMarker,
menu: &crate::primitives::context_menu::ContextMenu,
anchor_x: f64,
anchor_y: f64,
events: Rc<RefCell<VecDeque<UiEvent>>>,
) {
let target = QuadraMenuTarget::new(mtm, events.clone(), MenuKind::Context);
let ns_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(""),
]
};
let mut next_tag: isize = 1;
for item in &menu.items {
append_menu_item(mtm, &ns_menu, &target, item, &mut next_tag);
}
let ns_app = NSApplication::sharedApplication(mtm);
if let Some(window) = ns_app.keyWindow() {
if let Some(view) = window.contentView() {
let location = objc2_foundation::NSPoint::new(anchor_x, anchor_y);
ns_menu.popUpMenuPositioningItem_atLocation_inView(None, location, Some(&view));
}
}
events.borrow_mut().push_back(UiEvent::ContextMenuDismissed);
let _ = target;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accelerator::{AcceleratorId, AcceleratorScope};
use crate::types::{StyledText, WidgetId};
fn events() -> Rc<RefCell<VecDeque<UiEvent>>> {
Rc::new(RefCell::new(VecDeque::new()))
}
fn item(id: &str, label: &str) -> ContextMenuItem {
ContextMenuItem {
id: Some(WidgetId::new(id)),
label: StyledText::plain(label),
..Default::default()
}
}
#[test]
fn accelerator_save_maps_to_cmd_s() {
let acc = Accelerator {
id: AcceleratorId::new("editor.save"),
binding: KeyBinding::Save,
scope: AcceleratorScope::Global,
label: None,
};
let (key, mask) = accelerator_to_ns(&acc).expect("Save maps");
assert_eq!(key, "s");
assert_eq!(mask, NSEventModifierFlags::Command);
}
#[test]
fn accelerator_redo_maps_to_cmd_shift_z() {
let acc = Accelerator {
id: AcceleratorId::new("editor.redo"),
binding: KeyBinding::Redo,
scope: AcceleratorScope::Global,
label: None,
};
let (key, mask) = accelerator_to_ns(&acc).expect("Redo maps");
assert_eq!(key, "z");
assert_eq!(
mask,
NSEventModifierFlags::Command | NSEventModifierFlags::Shift,
);
}
#[test]
fn accelerator_literal_ctrl_shift_t_maps() {
let acc = Accelerator {
id: AcceleratorId::new("editor.reopen-tab"),
binding: KeyBinding::Literal("Ctrl+Shift+T".into()),
scope: AcceleratorScope::Global,
label: None,
};
let (key, mask) = accelerator_to_ns(&acc).expect("Literal maps");
assert_eq!(key, "t");
assert!(mask.contains(NSEventModifierFlags::Control));
assert!(mask.contains(NSEventModifierFlags::Shift));
}
#[test]
fn install_then_simulate_activation_pushes_event() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let events = events();
let bar = MenuBar {
id: WidgetId::new("menubar"),
items: vec![MenuBarItem {
id: WidgetId::new("file"),
label: "&File".into(),
disabled: false,
submenu: Some(vec![item("file.open", "Open…"), item("file.save", "Save")]),
}],
open_item: None,
focused_item: None,
};
let target = install_menu_bar(mtm, &bar, events.clone());
assert_eq!(target.registered_id(1), Some(WidgetId::new("file.open")));
assert_eq!(target.registered_id(2), Some(WidgetId::new("file.save")));
target.simulate_activation(2);
let queued: Vec<_> = events.borrow().iter().cloned().collect();
assert_eq!(
queued,
vec![UiEvent::MenuActivated(WidgetId::new("file.save"))]
);
}
#[test]
fn separator_in_submenu_does_not_consume_a_tag() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let bar = MenuBar {
id: WidgetId::new("menubar"),
items: vec![MenuBarItem {
id: WidgetId::new("edit"),
label: "&Edit".into(),
disabled: false,
submenu: Some(vec![
item("edit.cut", "Cut"),
ContextMenuItem::default(), item("edit.copy", "Copy"),
]),
}],
open_item: None,
focused_item: None,
};
let target = install_menu_bar(mtm, &bar, events());
assert_eq!(target.registered_id(1), Some(WidgetId::new("edit.cut")));
assert_eq!(target.registered_id(2), Some(WidgetId::new("edit.copy")));
assert_eq!(target.registered_id(3), None);
}
#[test]
fn nested_submenu_walks_recursively() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let bar = MenuBar {
id: WidgetId::new("menubar"),
items: vec![MenuBarItem {
id: WidgetId::new("view"),
label: "View".into(),
disabled: false,
submenu: Some(vec![ContextMenuItem {
id: Some(WidgetId::new("appearance")),
label: StyledText::plain("Appearance"),
submenu: Some(vec![item("appearance.zoom_in", "Zoom In")]),
..Default::default()
}]),
}],
open_item: None,
focused_item: None,
};
let target = install_menu_bar(mtm, &bar, events());
assert_eq!(
target.registered_id(1),
Some(WidgetId::new("appearance.zoom_in")),
);
}
fn find_installed_top_level_menu(
mtm: MainThreadMarker,
title: &str,
) -> Option<Retained<NSMenu>> {
let main_menu = NSApplication::sharedApplication(mtm).mainMenu()?;
find_top_level_menu(&main_menu, &[title])
}
fn find_full_screen_item(menu: &NSMenu) -> Option<Retained<NSMenuItem>> {
menu.itemArray()
.iter()
.find(|item| item.action() == Some(sel!(toggleFullScreen:)))
}
#[test]
fn full_screen_item_creates_view_menu_when_none_exists() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let bar = MenuBar {
id: WidgetId::new("menubar"),
items: vec![MenuBarItem {
id: WidgetId::new("file"),
label: "&File".into(),
disabled: false,
submenu: Some(vec![item("file.open", "Open…")]),
}],
open_item: None,
focused_item: None,
};
let _target = install_menu_bar(mtm, &bar, events());
let view_menu =
find_installed_top_level_menu(mtm, "View").expect("a View menu must be synthesized");
let fs_item =
find_full_screen_item(&view_menu).expect("Enter Full Screen item must be present");
assert_eq!(fs_item.title().to_string(), "Enter Full Screen");
assert_eq!(fs_item.keyEquivalent().to_string(), "f");
assert_eq!(
fs_item.keyEquivalentModifierMask(),
NSEventModifierFlags::Command | NSEventModifierFlags::Control,
"Ctrl+Cmd+F is the standard AppKit full-screen shortcut",
);
}
#[test]
fn full_screen_item_reuses_existing_view_menu() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let bar = MenuBar {
id: WidgetId::new("menubar"),
items: vec![MenuBarItem {
id: WidgetId::new("view"),
label: "View".into(),
disabled: false,
submenu: Some(vec![item("view.zoom_in", "Zoom In")]),
}],
open_item: None,
focused_item: None,
};
let _target = install_menu_bar(mtm, &bar, events());
let main_menu = NSApplication::sharedApplication(mtm)
.mainMenu()
.expect("main menu installed");
let view_menus: Vec<_> = main_menu
.itemArray()
.iter()
.filter(|item| {
item.submenu()
.is_some_and(|s| s.title().to_string().eq_ignore_ascii_case("view"))
})
.collect();
assert_eq!(
view_menus.len(),
1,
"must not create a second top-level View menu when one already exists",
);
let view_menu = view_menus[0].submenu().unwrap();
find_full_screen_item(&view_menu)
.expect("Enter Full Screen item must land in the app's existing View menu");
assert!(
view_menu
.itemArray()
.iter()
.any(|i| i.title().to_string() == "Zoom In"),
"the app's own View-menu item must survive alongside the injected full-screen item",
);
}
fn context_menu(items: Vec<ContextMenuItem>) -> crate::primitives::context_menu::ContextMenu {
crate::primitives::context_menu::ContextMenu {
id: WidgetId::new("ctx"),
items,
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
}
}
fn build_context_target(
mtm: MainThreadMarker,
menu: &crate::primitives::context_menu::ContextMenu,
ev: Rc<RefCell<VecDeque<UiEvent>>>,
) -> Retained<QuadraMenuTarget> {
let target = QuadraMenuTarget::new(mtm, ev, MenuKind::Context);
let ns_menu: Retained<NSMenu> = unsafe {
msg_send![
mtm.alloc::<NSMenu>(),
initWithTitle: &*NSString::from_str(""),
]
};
let mut next_tag: isize = 1;
for item in &menu.items {
append_menu_item(mtm, &ns_menu, &target, item, &mut next_tag);
}
target
}
#[test]
fn context_menu_activation_pushes_context_variant() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let ev = events();
let menu = context_menu(vec![item("ctx.copy", "Copy"), item("ctx.paste", "Paste")]);
let target = build_context_target(mtm, &menu, ev.clone());
target.simulate_activation(1);
target.simulate_activation(2);
let queued: Vec<_> = ev.borrow().iter().cloned().collect();
assert_eq!(
queued,
vec![
UiEvent::ContextMenuItemActivated(WidgetId::new("ctx.copy")),
UiEvent::ContextMenuItemActivated(WidgetId::new("ctx.paste")),
],
);
}
#[test]
fn context_menu_separator_does_not_consume_a_tag() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let menu = context_menu(vec![
item("ctx.cut", "Cut"),
ContextMenuItem::default(), item("ctx.copy", "Copy"),
]);
let target = build_context_target(mtm, &menu, events());
assert_eq!(target.registered_id(1), Some(WidgetId::new("ctx.cut")));
assert_eq!(target.registered_id(2), Some(WidgetId::new("ctx.copy")));
assert_eq!(target.registered_id(3), None);
}
#[test]
fn menu_bar_and_context_target_share_tag_numbering_independently() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let bar = MenuBar {
id: WidgetId::new("menubar"),
items: vec![MenuBarItem {
id: WidgetId::new("file"),
label: "File".into(),
disabled: false,
submenu: Some(vec![item("file.save", "Save")]),
}],
open_item: None,
focused_item: None,
};
let menu = context_menu(vec![item("ctx.copy", "Copy")]);
let ev = events();
let bar_target = install_menu_bar(mtm, &bar, ev.clone());
let ctx_target = build_context_target(mtm, &menu, ev.clone());
assert_eq!(
bar_target.registered_id(1),
Some(WidgetId::new("file.save")),
);
assert_eq!(ctx_target.registered_id(1), Some(WidgetId::new("ctx.copy")),);
bar_target.simulate_activation(1);
ctx_target.simulate_activation(1);
let queued: Vec<_> = ev.borrow().iter().cloned().collect();
assert_eq!(
queued,
vec![
UiEvent::MenuActivated(WidgetId::new("file.save")),
UiEvent::ContextMenuItemActivated(WidgetId::new("ctx.copy")),
],
);
}
}