use std::collections::HashMap;
use std::sync::Arc;
use teksilo_core::AppEventPoster;
use teksilo_core::MenuItemId;
use teksilo_core::window::TeksiloWindowId;
use objc2::rc::Retained;
use objc2::runtime::{NSObject, NSObjectProtocol, Sel};
use objc2::{DefinedClass, MainThreadMarker, define_class, msg_send, sel};
use objc2_app_kit::{NSApplication, NSControlStateValue, NSEventModifierFlags, NSMenu, NSMenuItem};
use objc2_foundation::{NSString, ns_string};
use super::{
MenuItemDelta, NativeCheck, NativeKeyEquivalent, NativeMenuBackend, NativeMenuEventPayload,
NativeMenuNode, NativeMenuSnapshot, StandardLabels, StandardMenuRole, StandardRoutedItem,
};
struct MenuTargetIvars {
window_id: TeksiloWindowId,
poster: Arc<dyn AppEventPoster>,
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "TeksiloMenuTarget"]
#[ivars = MenuTargetIvars]
struct TeksiloMenuTarget;
unsafe impl NSObjectProtocol for TeksiloMenuTarget {}
impl TeksiloMenuTarget {
#[unsafe(method(teksiloMenuAction:))]
fn menu_action(&self, sender: &NSMenuItem) {
let tag = sender.tag();
if tag <= 0 {
return;
}
let ivars = self.ivars();
ivars.poster.post_external(Box::new(NativeMenuEventPayload {
window_id_owner: ivars.window_id,
item_id: MenuItemId::from_raw(tag as u64),
}));
}
}
);
impl TeksiloMenuTarget {
fn new(
mtm: MainThreadMarker,
window_id: TeksiloWindowId,
poster: Arc<dyn AppEventPoster>,
) -> Retained<Self> {
let this = mtm
.alloc::<Self>()
.set_ivars(MenuTargetIvars { window_id, poster });
unsafe { msg_send![super(this), init] }
}
}
struct WindowMenu {
menu: Retained<NSMenu>,
_target: Retained<TeksiloMenuTarget>,
items: HashMap<MenuItemId, Retained<NSMenuItem>>,
}
#[derive(Default)]
pub struct MacOsNativeMenuBackend {
windows: HashMap<TeksiloWindowId, WindowMenu>,
active: Option<TeksiloWindowId>,
}
impl MacOsNativeMenuBackend {
pub fn new() -> Self {
Self::default()
}
fn apply_main_menu(&self, mtm: MainThreadMarker, window_id: TeksiloWindowId) {
if let Some(wm) = self.windows.get(&window_id) {
let app = NSApplication::sharedApplication(mtm);
app.setMainMenu(Some(&wm.menu));
}
}
}
impl NativeMenuBackend for MacOsNativeMenuBackend {
fn set_window_menu(
&mut self,
window_id: TeksiloWindowId,
menu: NativeMenuSnapshot,
poster: Arc<dyn AppEventPoster>,
) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let target = TeksiloMenuTarget::new(mtm, window_id, poster);
let mut items = HashMap::new();
let root = build_root_menu(mtm, &menu, &target, &mut items);
self.windows.insert(
window_id,
WindowMenu {
menu: root,
_target: target,
items,
},
);
let make_active = match self.active {
None => true,
Some(active) => active == window_id,
};
if make_active {
self.active = Some(window_id);
self.apply_main_menu(mtm, window_id);
}
}
fn activate_window(&mut self, window_id: TeksiloWindowId) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if self.windows.contains_key(&window_id) {
self.active = Some(window_id);
self.apply_main_menu(mtm, window_id);
}
}
fn clear_window(&mut self, window_id: TeksiloWindowId) {
self.windows.remove(&window_id);
if self.active != Some(window_id) {
return;
}
self.active = None;
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if let Some(&next) = self.windows.keys().next() {
self.active = Some(next);
self.apply_main_menu(mtm, next);
} else {
NSApplication::sharedApplication(mtm).setMainMenu(None);
}
}
fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
for wm in self.windows.values() {
let Some(item) = wm.items.get(&id) else {
continue;
};
if let Some(enabled) = delta.enabled {
item.setEnabled(enabled);
}
if let Some(check) = delta.check {
item.setState(control_state(check));
}
if let Some(title) = &delta.title {
item.setTitle(&NSString::from_str(title));
}
if let Some(key_equiv) = &delta.key_equiv {
apply_key_equiv(item, key_equiv.as_ref());
}
}
}
}
fn build_root_menu(
mtm: MainThreadMarker,
snapshot: &NativeMenuSnapshot,
target: &TeksiloMenuTarget,
items: &mut HashMap<MenuItemId, Retained<NSMenuItem>>,
) -> Retained<NSMenu> {
let bar = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), ns_string!(""));
bar.setAutoenablesItems(false);
for node in &snapshot.roots {
match node {
NativeMenuNode::Standard {
role: StandardMenuRole::App,
labels,
quit_item,
settings_item,
} => {
bar.addItem(&app_menu_item(
mtm,
labels,
quit_item.as_ref(),
settings_item.as_ref(),
target,
items,
));
}
NativeMenuNode::Standard {
role: StandardMenuRole::Window,
labels,
..
} => {
let item = top_level_item(mtm, &labels.title);
let sub = NSMenu::initWithTitle(
mtm.alloc::<NSMenu>(),
&NSString::from_str(&labels.title),
);
sub.setAutoenablesItems(false);
sub.addItem(&standard_item(
mtm,
&labels.minimize,
sel!(performMiniaturize:),
"m",
));
sub.addItem(&standard_item(mtm, &labels.zoom, sel!(performZoom:), ""));
sub.addItem(&NSMenuItem::separatorItem(mtm));
item.setSubmenu(Some(&sub));
bar.addItem(&item);
NSApplication::sharedApplication(mtm).setWindowsMenu(Some(&sub));
}
NativeMenuNode::Standard {
role: StandardMenuRole::Help,
labels,
..
} => {
let item = top_level_item(mtm, &labels.title);
let sub = NSMenu::initWithTitle(
mtm.alloc::<NSMenu>(),
&NSString::from_str(&labels.title),
);
sub.setAutoenablesItems(false);
item.setSubmenu(Some(&sub));
bar.addItem(&item);
NSApplication::sharedApplication(mtm).setHelpMenu(Some(&sub));
}
NativeMenuNode::Submenu { title, children } => {
let item = top_level_item(mtm, title);
let sub = build_submenu(mtm, title, children, target, items);
item.setSubmenu(Some(&sub));
bar.addItem(&item);
}
NativeMenuNode::Item { .. } | NativeMenuNode::Separator => {}
}
}
bar
}
fn build_submenu(
mtm: MainThreadMarker,
title: &str,
nodes: &[NativeMenuNode],
target: &TeksiloMenuTarget,
items: &mut HashMap<MenuItemId, Retained<NSMenuItem>>,
) -> Retained<NSMenu> {
let menu = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), &NSString::from_str(title));
menu.setAutoenablesItems(false);
for node in nodes {
match node {
NativeMenuNode::Separator => {
menu.addItem(&NSMenuItem::separatorItem(mtm));
}
NativeMenuNode::Item {
id,
title,
key_equiv,
enabled,
check,
} => {
let item = leaf_item(mtm, title, target);
item.setTag(id.raw() as isize);
item.setEnabled(*enabled);
item.setState(control_state(*check));
apply_key_equiv(&item, key_equiv.as_ref());
menu.addItem(&item);
items.insert(*id, item);
}
NativeMenuNode::Submenu { title, children } => {
let item = top_level_item(mtm, title);
let sub = build_submenu(mtm, title, children, target, items);
item.setSubmenu(Some(&sub));
menu.addItem(&item);
}
NativeMenuNode::Standard { .. } => {}
}
}
menu
}
fn top_level_item(mtm: MainThreadMarker, title: &str) -> Retained<NSMenuItem> {
let item = mtm.alloc::<NSMenuItem>();
unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
item,
&NSString::from_str(title),
None,
ns_string!(""),
)
}
}
fn leaf_item(
mtm: MainThreadMarker,
title: &str,
target: &TeksiloMenuTarget,
) -> Retained<NSMenuItem> {
let item = mtm.alloc::<NSMenuItem>();
let item: Retained<NSMenuItem> = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
item,
&NSString::from_str(title),
Some(sel!(teksiloMenuAction:)),
ns_string!(""),
)
};
unsafe {
item.setTarget(Some(target));
}
item
}
fn apply_key_equiv(item: &NSMenuItem, key: Option<&NativeKeyEquivalent>) {
match key {
None => {
item.setKeyEquivalent(ns_string!(""));
}
Some(k) => {
item.setKeyEquivalent(&NSString::from_str(&k.key));
let mut mask = NSEventModifierFlags::empty();
if k.command {
mask |= NSEventModifierFlags::Command;
}
if k.shift {
mask |= NSEventModifierFlags::Shift;
}
if k.alt {
mask |= NSEventModifierFlags::Option;
}
if k.control {
mask |= NSEventModifierFlags::Control;
}
item.setKeyEquivalentModifierMask(mask);
}
}
}
fn control_state(check: NativeCheck) -> NSControlStateValue {
match check {
NativeCheck::On => 1,
NativeCheck::Mixed => -1,
NativeCheck::Off | NativeCheck::None => 0,
}
}
fn app_menu_item(
mtm: MainThreadMarker,
labels: &StandardLabels,
quit_item: Option<&StandardRoutedItem>,
settings_item: Option<&StandardRoutedItem>,
target: &TeksiloMenuTarget,
items: &mut HashMap<MenuItemId, Retained<NSMenuItem>>,
) -> Retained<NSMenuItem> {
let bar_item = top_level_item(mtm, &labels.title);
let menu = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), &NSString::from_str(&labels.title));
menu.setAutoenablesItems(false);
menu.addItem(&standard_item(
mtm,
&labels.about,
sel!(orderFrontStandardAboutPanel:),
"",
));
if let Some(routed) = settings_item {
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&routed_item(mtm, &labels.settings, routed, target, items));
}
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&standard_item(mtm, &labels.hide, sel!(hide:), "h"));
menu.addItem(&NSMenuItem::separatorItem(mtm));
match quit_item {
None => menu.addItem(&standard_item(mtm, &labels.quit, sel!(terminate:), "q")),
Some(routed) => menu.addItem(&routed_item(mtm, &labels.quit, routed, target, items)),
}
bar_item.setSubmenu(Some(&menu));
bar_item
}
fn routed_item(
mtm: MainThreadMarker,
title: &str,
routed: &StandardRoutedItem,
target: &TeksiloMenuTarget,
items: &mut HashMap<MenuItemId, Retained<NSMenuItem>>,
) -> Retained<NSMenuItem> {
let item = leaf_item(mtm, title, target);
item.setTag(routed.id.raw() as isize);
item.setEnabled(true);
apply_key_equiv(&item, routed.key_equiv.as_ref());
items.insert(routed.id, item.clone());
item
}
fn standard_item(
mtm: MainThreadMarker,
title: &str,
action: Sel,
key: &str,
) -> Retained<NSMenuItem> {
let item = mtm.alloc::<NSMenuItem>();
let item: Retained<NSMenuItem> = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
item,
&NSString::from_str(title),
Some(action),
&NSString::from_str(key),
)
};
if !key.is_empty() {
item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command);
}
item
}