use incular_desktop::{
NativeMenuCommandRegistry, NativeMenuMessage, matching_menu_shortcut,
native_menu_structure_equal,
};
use incular_widgets::{
MenuItemId, MenuOwnerId, PlatformMenuDelegate, PlatformMenuEvent, PlatformMenuSnapshot,
PlatformMenuSnapshotNode, PlatformMenuUpdate, ShortcutModifiers,
};
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2::{ClassType, DeclaredClass, declare_class, msg_send_id, mutability, sel};
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSMenu, NSMenuDelegate, NSMenuItem};
use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol, NSString};
use std::{
cell::RefCell,
collections::{HashMap, VecDeque},
rc::Rc,
};
struct DispatcherIvars {
messages: Rc<RefCell<VecDeque<NativeMenuMessage<usize>>>>,
}
declare_class!(
struct PlatformMenuDispatcher;
unsafe impl ClassType for PlatformMenuDispatcher {
type Super = NSObject;
type Mutability = mutability::MainThreadOnly;
const NAME: &'static str = "IncularPlatformMenuDispatcher";
}
impl DeclaredClass for PlatformMenuDispatcher {
type Ivars = DispatcherIvars;
}
unsafe impl NSObjectProtocol for PlatformMenuDispatcher {}
unsafe impl PlatformMenuDispatcher {
#[method(incularMenuItemSelected:)]
fn selected(&self, sender: &NSMenuItem) {
let tag = unsafe { sender.tag() };
if let Ok(command) = u32::try_from(tag) {
self.ivars()
.messages
.borrow_mut()
.push_back(NativeMenuMessage::Command(command));
}
}
}
unsafe impl NSMenuDelegate for PlatformMenuDispatcher {
#[method(menuWillOpen:)]
fn menu_will_open(&self, menu: &NSMenu) {
self.ivars()
.messages
.borrow_mut()
.push_back(NativeMenuMessage::Opened(menu as *const NSMenu as usize));
}
#[method(menuDidClose:)]
fn menu_did_close(&self, menu: &NSMenu) {
self.ivars()
.messages
.borrow_mut()
.push_back(NativeMenuMessage::Closed(menu as *const NSMenu as usize));
}
}
);
impl PlatformMenuDispatcher {
fn new(
messages: Rc<RefCell<VecDeque<NativeMenuMessage<usize>>>>,
mtm: MainThreadMarker,
) -> Retained<Self> {
let this = mtm.alloc().set_ivars(DispatcherIvars { messages });
unsafe { msg_send_id![super(this), init] }
}
}
#[derive(Default)]
struct MacosMenuState {
owner: Option<MenuOwnerId>,
snapshot: Option<PlatformMenuSnapshot>,
event_handler: Option<Rc<dyn Fn(PlatformMenuEvent)>>,
registry: NativeMenuCommandRegistry,
baseline: Option<BaselineMainMenu>,
menu_ids: HashMap<usize, MenuItemId>,
dispatcher: Option<Retained<PlatformMenuDispatcher>>,
}
struct BaselineMainMenu {
original: Option<Retained<NSMenu>>,
root: Retained<NSMenu>,
original_item_count: usize,
}
pub(crate) struct MacosPlatformMenuDelegate {
state: RefCell<MacosMenuState>,
native_messages: Rc<RefCell<VecDeque<NativeMenuMessage<usize>>>>,
}
impl Default for MacosPlatformMenuDelegate {
fn default() -> Self {
Self {
state: RefCell::new(MacosMenuState::default()),
native_messages: Rc::new(RefCell::new(VecDeque::new())),
}
}
}
impl MacosPlatformMenuDelegate {
pub(crate) fn flush_events(&self) {
let messages = self
.native_messages
.borrow_mut()
.drain(..)
.collect::<Vec<_>>();
if messages.is_empty() {
return;
}
let (handler, events) = {
let state = self.state.borrow();
let Some(handler) = state.event_handler.clone() else {
return;
};
(
handler,
state.registry.resolve_messages(messages, &state.menu_ids),
)
};
for event in events {
handler(event);
}
}
}
impl PlatformMenuDelegate for MacosPlatformMenuDelegate {
fn acquire(&self, owner: MenuOwnerId) -> PlatformMenuUpdate {
let mut state = self.state.borrow_mut();
match state.owner {
None => {
state.owner = Some(owner);
PlatformMenuUpdate::Applied
}
Some(active) if active == owner => PlatformMenuUpdate::Applied,
Some(_) => PlatformMenuUpdate::RejectedOwnedByOther,
}
}
fn set_menus(&self, owner: MenuOwnerId, snapshot: PlatformMenuSnapshot) -> PlatformMenuUpdate {
let Some(mtm) = MainThreadMarker::new() else {
return PlatformMenuUpdate::RejectedByPlatform;
};
let mut state = self.state.borrow_mut();
if state.owner != Some(owner) {
return PlatformMenuUpdate::RejectedOwnedByOther;
}
if state.snapshot.as_ref() == Some(&snapshot) {
return PlatformMenuUpdate::Unchanged;
}
let mut next_registry = state.registry.clone();
if next_registry.synchronize(&snapshot).is_err() {
return PlatformMenuUpdate::RejectedByPlatform;
}
let dispatcher = state
.dispatcher
.get_or_insert_with(|| PlatformMenuDispatcher::new(self.native_messages.clone(), mtm))
.clone();
let application = NSApplication::sharedApplication(mtm);
if state.baseline.is_none() {
state.baseline = Some(capture_baseline_main_menu(&application, mtm));
}
if state
.snapshot
.as_ref()
.is_some_and(|current| native_menu_structure_equal(current, &snapshot))
{
let baseline = state
.baseline
.as_ref()
.expect("AppKit baseline captured before update");
if !update_menu_nodes(
&baseline.root,
baseline.original_item_count,
&snapshot.menus,
&next_registry,
) {
if let Some(previous) = state.snapshot.as_ref() {
let _ = update_menu_nodes(
&baseline.root,
baseline.original_item_count,
&previous.menus,
&state.registry,
);
}
return PlatformMenuUpdate::RejectedByPlatform;
}
} else {
let mut menu_ids = HashMap::new();
let items = build_menu_items(
&snapshot.menus,
&next_registry,
&dispatcher,
mtm,
&mut menu_ids,
);
let baseline = state
.baseline
.as_ref()
.expect("AppKit baseline captured before replacement");
remove_owned_top_level_items(&baseline.root, baseline.original_item_count);
for item in items {
baseline.root.addItem(&item);
}
application.setMainMenu(Some(&baseline.root));
state.menu_ids = menu_ids;
}
state.registry = next_registry;
state.snapshot = Some(snapshot);
PlatformMenuUpdate::Applied
}
fn clear_menus(&self, owner: MenuOwnerId) -> PlatformMenuUpdate {
let mut state = self.state.borrow_mut();
if state.owner != Some(owner) {
return PlatformMenuUpdate::RejectedOwnedByOther;
}
if let (Some(mtm), Some(baseline)) = (MainThreadMarker::new(), state.baseline.as_ref()) {
remove_owned_top_level_items(&baseline.root, baseline.original_item_count);
NSApplication::sharedApplication(mtm).setMainMenu(baseline.original.as_deref());
}
state.menu_ids.clear();
let _ = state
.registry
.synchronize(&PlatformMenuSnapshot { menus: Vec::new() });
state.snapshot = None;
PlatformMenuUpdate::Applied
}
fn release(&self, owner: MenuOwnerId) -> PlatformMenuUpdate {
let mut state = self.state.borrow_mut();
if state.owner != Some(owner) {
return PlatformMenuUpdate::RejectedOwnedByOther;
}
state.owner = None;
state.event_handler = None;
PlatformMenuUpdate::Applied
}
fn set_event_handler(
&self,
owner: MenuOwnerId,
handler: Option<Rc<dyn Fn(PlatformMenuEvent)>>,
) -> PlatformMenuUpdate {
let mut state = self.state.borrow_mut();
if state.owner != Some(owner) {
return PlatformMenuUpdate::RejectedOwnedByOther;
}
state.event_handler = handler;
PlatformMenuUpdate::Applied
}
fn handle_shortcut(&self, key: &str, modifiers: ShortcutModifiers) -> bool {
self.state
.borrow()
.snapshot
.as_ref()
.is_some_and(|snapshot| matching_menu_shortcut(snapshot, key, modifiers).is_some())
}
}
fn capture_baseline_main_menu(
application: &NSApplication,
mtm: MainThreadMarker,
) -> BaselineMainMenu {
let original = unsafe { application.mainMenu() };
let root = original.clone().unwrap_or_else(|| {
let title = NSString::from_str("");
unsafe { NSMenu::initWithTitle(mtm.alloc(), &title) }
});
let original_item_count = usize::try_from(unsafe { root.numberOfItems() }).unwrap_or(0);
BaselineMainMenu {
original,
root,
original_item_count,
}
}
fn remove_owned_top_level_items(root: &NSMenu, original_item_count: usize) {
loop {
let count = usize::try_from(unsafe { root.numberOfItems() }).unwrap_or(0);
if count <= original_item_count {
break;
}
unsafe { root.removeItemAtIndex(isize::try_from(count - 1).expect("NSMenu index fits")) };
}
}
fn build_menu_items(
nodes: &[PlatformMenuSnapshotNode],
registry: &NativeMenuCommandRegistry,
dispatcher: &PlatformMenuDispatcher,
mtm: MainThreadMarker,
menu_ids: &mut HashMap<usize, MenuItemId>,
) -> Vec<Retained<NSMenuItem>> {
nodes
.iter()
.map(|node| build_menu_item(node, registry, dispatcher, mtm, menu_ids))
.collect()
}
fn append_menu_nodes(
parent: &NSMenu,
nodes: &[PlatformMenuSnapshotNode],
registry: &NativeMenuCommandRegistry,
dispatcher: &PlatformMenuDispatcher,
mtm: MainThreadMarker,
menu_ids: &mut HashMap<usize, MenuItemId>,
) {
for item in build_menu_items(nodes, registry, dispatcher, mtm, menu_ids) {
parent.addItem(&item);
}
}
fn build_menu_item(
node: &PlatformMenuSnapshotNode,
registry: &NativeMenuCommandRegistry,
dispatcher: &PlatformMenuDispatcher,
mtm: MainThreadMarker,
menu_ids: &mut HashMap<usize, MenuItemId>,
) -> Retained<NSMenuItem> {
if node.separator {
return NSMenuItem::separatorItem(mtm);
}
let title = NSString::from_str(&node.label);
let key = NSString::from_str(
node.shortcut
.as_ref()
.filter(|_| node.selectable)
.map_or("", |shortcut| shortcut.key.as_str()),
);
let action = node.selectable.then_some(sel!(incularMenuItemSelected:));
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(mtm.alloc(), &title, action, &key)
};
unsafe {
item.setEnabled(node.enabled);
if let Some(tooltip) = &node.tooltip {
item.setToolTip(Some(&NSString::from_str(tooltip)));
}
}
if node.selectable {
let command = registry
.command_for(&node.id)
.expect("selectable AppKit menu node has native command ID");
unsafe {
item.setTag(isize::try_from(command.get()).expect("AppKit NSInteger fits u32"));
item.setTarget(Some(dispatcher.as_super().as_super()));
}
item.setKeyEquivalentModifierMask(appkit_modifiers(
node.shortcut
.as_ref()
.map_or(ShortcutModifiers::empty(), |shortcut| shortcut.modifiers),
));
} else {
let submenu_title = NSString::from_str(&node.label);
let submenu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &submenu_title) };
unsafe {
submenu.setAutoenablesItems(false);
submenu.setDelegate(Some(ProtocolObject::from_ref(dispatcher)));
}
append_menu_nodes(
&submenu,
&node.children,
registry,
dispatcher,
mtm,
menu_ids,
);
menu_ids.insert(&*submenu as *const NSMenu as usize, node.id.clone());
item.setSubmenu(Some(&submenu));
}
item
}
fn update_menu_nodes(
parent: &NSMenu,
start_index: usize,
nodes: &[PlatformMenuSnapshotNode],
registry: &NativeMenuCommandRegistry,
) -> bool {
for (index, node) in nodes.iter().enumerate() {
let Some(index) = start_index.checked_add(index) else {
return false;
};
let Ok(index) = isize::try_from(index) else {
return false;
};
let Some(item) = (unsafe { parent.itemAtIndex(index) }) else {
return false;
};
if node.separator {
if !unsafe { item.isSeparatorItem() } {
return false;
}
continue;
}
unsafe {
item.setTitle(&NSString::from_str(&node.label));
item.setEnabled(node.enabled);
item.setToolTip(
node.tooltip
.as_ref()
.map(|value| NSString::from_str(value))
.as_deref(),
);
}
if node.selectable {
let Some(command) = registry.command_for(&node.id) else {
return false;
};
let shortcut = node.shortcut.as_ref();
unsafe {
item.setTag(isize::try_from(command.get()).expect("AppKit NSInteger fits u32"));
item.setKeyEquivalent(&NSString::from_str(
shortcut.map_or("", |value| value.key.as_str()),
));
}
item.setKeyEquivalentModifierMask(appkit_modifiers(
shortcut.map_or(ShortcutModifiers::empty(), |value| value.modifiers),
));
} else {
let Some(submenu) = (unsafe { item.submenu() }) else {
return false;
};
if !update_menu_nodes(&submenu, 0, &node.children, registry) {
return false;
}
}
}
true
}
fn appkit_modifiers(modifiers: ShortcutModifiers) -> NSEventModifierFlags {
let mut native = NSEventModifierFlags::empty();
if modifiers.contains(ShortcutModifiers::SHIFT) {
native |= NSEventModifierFlags::NSEventModifierFlagShift;
}
if modifiers.contains(ShortcutModifiers::CONTROL) {
native |= NSEventModifierFlags::NSEventModifierFlagControl;
}
if modifiers.contains(ShortcutModifiers::ALT) {
native |= NSEventModifierFlags::NSEventModifierFlagOption;
}
if modifiers.contains(ShortcutModifiers::META) {
native |= NSEventModifierFlags::NSEventModifierFlagCommand;
}
native
}