native/desktop-macos/src/macos/application_menu.rs (544 lines of code) (raw):
use core::slice;
use std::{cell::RefCell, thread_local};
use anyhow::{Result, anyhow};
use log::error;
use objc2::__framework_prelude::NSInteger;
use objc2::{MainThreadOnly, define_class, msg_send, rc::Retained, sel};
use objc2_app_kit::{
NSControlStateValueMixed, NSControlStateValueOff, NSControlStateValueOn, NSEventModifierFlags, NSEventType, NSMenu, NSMenuItem,
};
use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol, NSString};
use super::{
application_api::MyNSApplication,
application_menu_api::{
ActionItemState, ActionMenuItemSpecialTag, AppMenuCallbacks, AppMenuItem, AppMenuStructure, AppMenuTrigger, ItemId,
SubMenuItemSpecialTag,
},
keyboard::KeyModifiersSet,
string::copy_to_ns_string,
};
thread_local! {
static GLOBAL_MENU_STATE: RefCell<Option<AppMenuCallbacks>> = const { RefCell::new(None) };
}
pub fn app_menu_init_impl(callbacks: AppMenuCallbacks) {
GLOBAL_MENU_STATE.with(|state| {
*state.borrow_mut() = Some(callbacks);
});
}
pub fn app_menu_deinit_impl() {
GLOBAL_MENU_STATE.with(|state| {
*state.borrow_mut() = None;
});
}
// Need to be called before ApplicationDidFinishLaunching
// to allow macOS to insert system-provided items
pub(crate) fn set_initial_app_menu() {
let dummy_menu = AppMenuStructureSafe {
items: vec![
AppMenuItemSafe::SubMenu {
title: NSString::from_str("App"),
special_tag: SubMenuItemSpecialTag::AppNameMenu,
items: vec![],
},
AppMenuItemSafe::SubMenu {
title: NSString::from_str("File"),
special_tag: SubMenuItemSpecialTag::None,
items: vec![],
},
AppMenuItemSafe::SubMenu {
title: NSString::from_str("Edit"),
special_tag: SubMenuItemSpecialTag::None,
items: vec![AppMenuItemSafe::Separator],
},
AppMenuItemSafe::SubMenu {
title: NSString::from_str("Window"),
special_tag: SubMenuItemSpecialTag::Window,
items: vec![],
},
AppMenuItemSafe::SubMenu {
title: NSString::from_str("Help"),
special_tag: SubMenuItemSpecialTag::Help,
items: vec![],
},
],
};
main_menu_update_impl(&dummy_menu);
}
pub(crate) fn main_menu_update_impl(menu: &AppMenuStructureSafe) {
let mtm: MainThreadMarker = MainThreadMarker::new().unwrap();
let app = MyNSApplication::sharedApplication(mtm);
let menu_root = if let Some(menu) = app.mainMenu() {
menu
} else {
let new_menu_root = NSMenu::new(mtm);
app.setMainMenu(Some(&new_menu_root));
new_menu_root
};
menu_root.setAutoenablesItems(false);
reconcile_ns_menu_items(mtm, &menu_root, true, &menu.items);
}
#[derive(Debug)]
struct AppMenuKeystrokeSafe {
key: Retained<NSString>,
modifiers: KeyModifiersSet,
}
#[derive(Debug)]
enum AppMenuItemSafe {
Action {
enabled: bool,
state: ActionItemState,
title: Retained<NSString>,
special_tag: ActionMenuItemSpecialTag,
keystroke: Option<AppMenuKeystrokeSafe>,
item_id: ItemId,
},
Separator,
SubMenu {
title: Retained<NSString>,
special_tag: SubMenuItemSpecialTag,
items: Vec<AppMenuItemSafe>,
},
}
#[derive(Debug)]
pub(crate) struct AppMenuStructureSafe {
items: Vec<AppMenuItemSafe>,
}
impl AppMenuStructureSafe {
pub(crate) fn from_unsafe(menu: &AppMenuStructure) -> Result<Self> {
let items = {
if menu.items.is_null() {
return Err(anyhow!("Null found in {menu:?}"));
}
unsafe { slice::from_raw_parts(menu.items, menu.items_count) }
};
let safe_items: Result<Vec<_>> = items.iter().map(|e| AppMenuItemSafe::from_unsafe(e)).collect();
Ok(Self { items: safe_items? })
}
}
impl AppMenuItemSafe {
fn from_unsafe(item: &AppMenuItem) -> Result<Self> {
let safe_item = match item {
&AppMenuItem::ActionItem {
enabled,
state,
ref title,
special_tag,
keystroke,
item_id,
} => {
let keystroke = if let Some(keystroke) = keystroke {
Some(AppMenuKeystrokeSafe {
key: copy_to_ns_string(&keystroke.key)?,
modifiers: keystroke.modifiers,
})
} else {
None
};
Self::Action {
enabled,
state,
title: copy_to_ns_string(title)?,
special_tag,
keystroke,
item_id,
}
}
AppMenuItem::SeparatorItem => Self::Separator,
sub_menu @ &AppMenuItem::SubMenuItem {
ref title,
special_tag,
items,
items_count,
} => {
let items = {
if items.is_null() {
return Err(anyhow!("Null found in {sub_menu:?}"));
}
unsafe { slice::from_raw_parts(items, items_count) }
};
let safe_items: Result<Vec<_>> = items.iter().map(|e| Self::from_unsafe(e)).collect();
Self::SubMenu {
title: copy_to_ns_string(title)?,
special_tag,
items: safe_items?,
}
}
};
Ok(safe_item)
}
#[allow(clippy::too_many_arguments)]
fn reconcile_action(
item: &NSMenuItem,
enabled: bool,
state: ActionItemState,
title: &Retained<NSString>,
special_tag: ActionMenuItemSpecialTag,
keystroke: Option<&AppMenuKeystrokeSafe>,
item_id: ItemId,
mtm: MainThreadMarker,
) {
unsafe {
item.setTitle(title);
item.setEnabled(enabled);
let state = match state {
ActionItemState::On => NSControlStateValueOn,
ActionItemState::Off => NSControlStateValueOff,
ActionItemState::Mixed => NSControlStateValueMixed,
};
item.setState(state);
let representer = MenuItemRepresenter::new(mtm);
item.setRepresentedObject(Some(&representer));
let selector = match special_tag {
ActionMenuItemSpecialTag::None => sel!(itemCallback:),
ActionMenuItemSpecialTag::Undo => sel!(undo:),
ActionMenuItemSpecialTag::Redo => sel!(redo:),
ActionMenuItemSpecialTag::Cut => sel!(cut:),
ActionMenuItemSpecialTag::Copy => sel!(copy:),
ActionMenuItemSpecialTag::Paste => sel!(paste:),
ActionMenuItemSpecialTag::SelectAll => sel!(selectAll:),
};
item.setAction(Some(selector));
item.setTag(NSInteger::try_from(item_id).unwrap());
if let Some(keystroke) = keystroke {
item.setKeyEquivalent(&keystroke.key);
item.setKeyEquivalentModifierMask(keystroke.modifiers.into());
} else {
item.setKeyEquivalent(&NSString::new());
item.setKeyEquivalentModifierMask(NSEventModifierFlags::empty());
}
}
}
fn reconcile_ns_submenu(
mtm: MainThreadMarker,
item: &NSMenuItem,
title: &Retained<NSString>,
special_tag: SubMenuItemSpecialTag,
items: &[Self],
) {
let submenu = item.submenu().unwrap();
if special_tag != SubMenuItemSpecialTag::AppNameMenu {
item.setTitle(title);
submenu.setTitle(title);
}
reconcile_ns_menu_items(mtm, &submenu, false, items);
}
fn reconcile_ns_menu_item(&self, mtm: MainThreadMarker, item: &NSMenuItem) {
match self {
Self::Action {
enabled,
state,
title,
special_tag,
keystroke,
item_id,
} => {
Self::reconcile_action(item, *enabled, *state, title, *special_tag, keystroke.as_ref(), *item_id, mtm);
}
Self::Separator => {
assert!(item.isSeparatorItem());
}
Self::SubMenu { title, special_tag, items } => {
Self::reconcile_ns_submenu(mtm, item, title, *special_tag, items);
}
}
}
fn create_ns_menu_item(&self, mtm: MainThreadMarker) -> Retained<NSMenuItem> {
match self {
&Self::Action {
enabled,
state,
ref title,
special_tag,
ref keystroke,
item_id,
} => {
let item = NSMenuItem::new(mtm);
Self::reconcile_action(&item, enabled, state, title, special_tag, keystroke.as_ref(), item_id, mtm);
item
}
Self::Separator => {
let item = NSMenuItem::separatorItem(mtm);
let representer = MenuItemRepresenter::new(mtm);
unsafe {
item.setRepresentedObject(Some(&representer));
};
item
}
Self::SubMenu { title, special_tag, items } => {
let item = NSMenuItem::new(mtm);
let representer = MenuItemRepresenter::new(mtm);
unsafe {
item.setRepresentedObject(Some(&representer));
};
let submenu = NSMenu::new(mtm);
submenu.setAutoenablesItems(false);
item.setSubmenu(Some(&submenu));
match special_tag {
SubMenuItemSpecialTag::Window => {
let app = MyNSApplication::sharedApplication(mtm);
app.setWindowsMenu(Some(&submenu));
}
SubMenuItemSpecialTag::Services => {
let app = MyNSApplication::sharedApplication(mtm);
app.setServicesMenu(Some(&submenu));
}
SubMenuItemSpecialTag::Help => {
let app = MyNSApplication::sharedApplication(mtm);
app.setHelpMenu(Some(&submenu));
}
_ => {}
}
Self::reconcile_ns_submenu(mtm, &item, title, *special_tag, items);
item
}
}
}
}
#[derive(Debug)]
struct MenuItemRepresenterIvars {}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "MenuItemRepresenter"]
#[ivars = MenuItemRepresenterIvars]
#[derive(Debug)]
struct MenuItemRepresenter;
unsafe impl NSObjectProtocol for MenuItemRepresenter {}
impl MenuItemRepresenter {
}
);
impl MenuItemRepresenter {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let obj = Self::alloc(mtm).set_ivars(MenuItemRepresenterIvars {});
unsafe { msg_send![super(obj), init] }
}
}
pub(crate) fn guess_trigger() -> AppMenuTrigger {
let mtm = MainThreadMarker::new().unwrap();
let app = MyNSApplication::sharedApplication(mtm);
match app.currentEvent() {
Some(ns_event) if ns_event.r#type() == NSEventType::KeyDown => AppMenuTrigger::Keystroke,
_ => AppMenuTrigger::Other,
}
}
pub(crate) fn handle_app_menu_callback(item_id: ItemId) {
GLOBAL_MENU_STATE.with(|state| {
if let Some(ref callbacks) = *state.borrow() {
let callback = callbacks.on_menu_action;
callback(item_id, guess_trigger());
} else {
error!("Can't trigger an item with id: {item_id}, global menu state isn't initialized");
}
});
}
#[derive(Debug, Eq, PartialEq, Clone, Copy)]
enum ItemIdentity<'a> {
Action { title: &'a Retained<NSString> },
Separator,
AppNameSubMenu,
SubMenu { title: &'a Retained<NSString> },
MacOSProvided,
}
impl<'a> ItemIdentity<'a> {
const fn new(item: &'a AppMenuItemSafe) -> Self {
match item {
AppMenuItemSafe::Action { title, .. } => Self::Action { title },
AppMenuItemSafe::Separator => Self::Separator,
AppMenuItemSafe::SubMenu {
special_tag: SubMenuItemSpecialTag::AppNameMenu,
..
} => Self::AppNameSubMenu,
AppMenuItemSafe::SubMenu { title, .. } => Self::SubMenu { title },
}
}
}
#[allow(clippy::cast_possible_wrap)]
fn reconcile_ns_menu_items(mtm: MainThreadMarker, menu: &NSMenu, is_top_level: bool, new_items: &[AppMenuItemSafe]) {
let items_array = menu.itemArray();
let menu_titles: Vec<_> = items_array.iter().map(|submenu| submenu.title()).collect();
// sometimes macOS can surround our items with new ones,
// but usually it just add items to the end
let old_item_ids: Vec<ItemIdentity> = items_array
.iter()
.zip(menu_titles.iter())
.enumerate()
.map(|(i, (item, title))| {
if is_top_level && i == 0 {
// the first item in a top level menu is always the item with the app name
ItemIdentity::AppNameSubMenu
} else {
item.representedObject()
.map(Retained::downcast::<MenuItemRepresenter>)
.map_or(ItemIdentity::MacOSProvided, |_rep_obj| {
if item.isSeparatorItem() {
ItemIdentity::Separator
} else if item.hasSubmenu() {
ItemIdentity::SubMenu { title }
} else {
ItemIdentity::Action { title }
}
})
}
})
.collect();
let new_item_ids: Vec<_> = new_items.iter().map(ItemIdentity::new).collect();
let first_item = old_item_ids.iter().position(|it| *it != ItemIdentity::MacOSProvided);
let last_item = old_item_ids.iter().rposition(|it| *it != ItemIdentity::MacOSProvided);
let (old_item_ids, base_position) = match (first_item, last_item) {
(Some(first_item), Some(last_item)) => (&old_item_ids[first_item..=last_item], first_item),
// All items in the menu are macOS provided
// Our items will be placed before them
_ => ([].as_slice(), 0),
};
let operations = edit_operations(old_item_ids, &new_item_ids);
let mut position_shift: isize = base_position as isize;
for op in operations {
match op {
Operation::Insert { position, item_idx } => {
let new_ns_menu_item = new_items[item_idx].create_ns_menu_item(mtm);
menu.insertItem_atIndex(&new_ns_menu_item, position as isize + position_shift);
position_shift += 1;
}
Operation::Reconcile { position, item_idx } => {
let ns_menu_item = menu.itemAtIndex(position as isize + position_shift).unwrap();
new_items[item_idx].reconcile_ns_menu_item(mtm, &ns_menu_item);
}
Operation::Remove { position } => {
let ns_menu_item = menu.itemAtIndex(position as isize + position_shift).unwrap();
let rep_obj = ns_menu_item.representedObject().map(Retained::downcast::<MenuItemRepresenter>);
// Just skip remove commands for macOS provided items
if rep_obj.is_some() {
menu.removeItemAtIndex(position as isize + position_shift);
position_shift -= 1;
}
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum Operation {
Insert { position: usize, item_idx: usize },
Reconcile { position: usize, item_idx: usize },
Remove { position: usize },
}
fn edit_operations<T: Eq>(source: &[T], target: &[T]) -> Vec<Operation> {
let m = source.len();
let n = target.len();
let mut dp = vec![vec![0; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
if source[i - 1] == target[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
let mut operations = Vec::new();
let mut i = m;
let mut j = n;
while i > 0 && j > 0 {
if source[i - 1] == target[j - 1] {
operations.push(Operation::Reconcile {
position: i - 1,
item_idx: j - 1,
});
i -= 1;
j -= 1;
} else if dp[i - 1][j] > dp[i][j - 1] {
operations.push(Operation::Remove { position: i - 1 });
i -= 1;
} else {
operations.push(Operation::Insert {
position: i,
item_idx: j - 1,
});
j -= 1;
}
}
while i > 0 {
operations.push(Operation::Remove { position: i - 1 });
i -= 1;
}
while j > 0 {
operations.push(Operation::Insert {
position: i,
item_idx: j - 1,
});
j -= 1;
}
operations.reverse();
operations
}
#[cfg(test)]
mod tests {
use std::{char, fmt::Debug};
use log::debug;
use quickcheck_macros::quickcheck;
use super::*;
fn chs(s: &str) -> Vec<char> {
s.chars().collect()
}
fn apply_operations<T: Debug + Clone + Copy + Eq>(source: &[T], target: &[T], operations: &[Operation]) -> Result<Vec<T>> {
let mut v = source.to_vec();
for op in operations {
match *op {
Operation::Insert { position, item_idx } => {
v.insert(position, target[item_idx]);
}
Operation::Reconcile { position, item_idx } => {
if v[position] != target[item_idx] {
return Err(anyhow!("{:?} != {:?}", v[position], target[item_idx]));
}
}
Operation::Remove { position } => {
v.remove(position);
}
}
}
Ok(v)
}
#[allow(clippy::cast_possible_wrap)]
fn fix_positions(operations: &mut [Operation]) {
let mut shift: isize = 0;
for operation in operations {
match operation {
Operation::Insert { position, item_idx: _ } => {
*position = (*position as isize + shift).try_into().unwrap();
shift += 1;
}
Operation::Reconcile { position, item_idx: _ } => {
*position = (*position as isize + shift).try_into().unwrap();
}
Operation::Remove { position } => {
*position = (*position as isize + shift).try_into().unwrap();
shift -= 1;
}
}
}
}
fn test_with(source: &str, target: &str) {
let source = chs(source);
let target = chs(target);
let mut operations = edit_operations(&source, &target);
fix_positions(&mut operations);
debug!("src: {source:?}, dst: {target:?}");
debug!("{operations:?}");
let result = apply_operations(&source, &target, &operations).unwrap();
assert_eq!(result, target);
}
#[test]
fn test_edit_operations_smoke() {
test_with("", "");
test_with("x", "x");
test_with("", "abcde");
test_with("abcde", "");
test_with("abc", "cba");
test_with("xxxxxxxx", "yy");
test_with("ab", "ba");
test_with("xxabcde", "abcde");
test_with("abcde", "xxabcde");
test_with("abcdexx", "abcde");
test_with("abcde", "abcdexx");
}
#[allow(clippy::needless_pass_by_value)]
#[quickcheck]
fn operations_turns_source_into_target(source: Vec<u32>, target: Vec<u32>) -> bool {
let mut operations = edit_operations(&source, &target);
fix_positions(&mut operations);
let result = apply_operations(&source, &target, &operations).unwrap();
target == result
}
}