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, NSWindow,
};
use objc2_foundation::{NSString, ns_string};
use super::{
MacAppMenuRow, MenuItemDelta, NativeAppMenuEventPayload, NativeCheck, NativeKeyEquivalent,
NativeMenuBackend, NativeMenuEventPayload, NativeMenuNode, NativeMenuSnapshot, StandardLabels,
StandardMenuRole, StandardRoutedItem, bar_after_app_menu, bar_after_clear, key_window_bar,
mac_app_menu_rows, mac_bar_plan, mac_relists_window,
};
struct MenuTargetIvars {
window_id: Option<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();
let item_id = MenuItemId::from_raw(tag as u64);
match ivars.window_id {
Some(window_id_owner) => ivars.poster.post_external(Box::new(
NativeMenuEventPayload {
window_id_owner,
item_id,
},
)),
None => ivars
.poster
.post_external(Box::new(NativeAppMenuEventPayload { item_id })),
}
}
}
);
impl TeksiloMenuTarget {
fn new(
mtm: MainThreadMarker,
window_id: Option<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>,
windows_menu: Retained<NSMenu>,
help_menu: Option<Retained<NSMenu>>,
services_menu: Option<Retained<NSMenu>>,
_target: Retained<TeksiloMenuTarget>,
items: HashMap<MenuItemId, Retained<NSMenuItem>>,
}
impl WindowMenu {
fn show(&self, mtm: MainThreadMarker) {
let app = NSApplication::sharedApplication(mtm);
app.setMainMenu(Some(&self.menu));
app.setHelpMenu(self.help_menu.as_deref());
if let Some(services) = &self.services_menu {
app.setServicesMenu(Some(services));
}
register_windows_menu(&app, &self.windows_menu);
}
}
fn register_windows_menu(app: &NSApplication, menu: &NSMenu) {
if app
.windowsMenu()
.is_some_and(|current| std::ptr::eq(&*current, menu))
{
return;
}
let listed: Vec<Retained<NSWindow>> = app
.windows()
.iter()
.filter(|window| {
mac_relists_window(
window.isExcludedFromWindowsMenu(),
window.isVisible(),
window.isMiniaturized(),
)
})
.collect();
for window in &listed {
window.setExcludedFromWindowsMenu(true);
}
app.setWindowsMenu(Some(menu));
for window in &listed {
window.setExcludedFromWindowsMenu(false);
}
}
#[derive(Default)]
pub struct MacOsNativeMenuBackend {
windows: HashMap<TeksiloWindowId, WindowMenu>,
active: Option<TeksiloWindowId>,
key: Option<TeksiloWindowId>,
app: Option<WindowMenu>,
app_declared: bool,
owners: HashMap<TeksiloWindowId, TeksiloWindowId>,
}
impl MacOsNativeMenuBackend {
pub fn new() -> Self {
Self::default()
}
fn bar_for(&self, key: TeksiloWindowId) -> Option<TeksiloWindowId> {
key_window_bar(
key,
|window| self.windows.contains_key(&window),
|window| self.owners.get(&window).copied(),
self.app_declared,
self.active,
)
}
fn show(&mut self, mtm: MainThreadMarker, bar: Option<TeksiloWindowId>) {
self.active = bar;
match bar {
Some(window_id) => self.apply_main_menu(mtm, window_id),
None => self.apply_app_menu(mtm),
}
}
fn apply_main_menu(&self, mtm: MainThreadMarker, window_id: TeksiloWindowId) {
if let Some(wm) = self.windows.get(&window_id) {
wm.show(mtm);
}
}
fn apply_app_menu(&self, mtm: MainThreadMarker) {
match &self.app {
Some(app) => app.show(mtm),
None => NSApplication::sharedApplication(mtm).setMainMenu(None),
}
}
fn build(
mtm: MainThreadMarker,
window_id: Option<TeksiloWindowId>,
menu: &NativeMenuSnapshot,
poster: Arc<dyn AppEventPoster>,
) -> WindowMenu {
let target = TeksiloMenuTarget::new(mtm, window_id, poster);
let mut items = HashMap::new();
let bar = build_root_menu(mtm, menu, &target, &mut items);
WindowMenu {
menu: bar.menu,
windows_menu: bar.windows_menu,
help_menu: bar.help_menu,
services_menu: bar.services_menu,
_target: target,
items,
}
}
}
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;
};
self.windows
.insert(window_id, Self::build(mtm, Some(window_id), &menu, poster));
let bar = match self.key {
Some(key) => self.bar_for(key),
None => Some(window_id),
};
if bar == Some(window_id) || bar != self.active {
self.show(mtm, bar);
}
}
fn activate_window(&mut self, window_id: TeksiloWindowId) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
self.key = Some(window_id);
let bar = self.bar_for(window_id);
self.show(mtm, bar);
}
fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
match owner {
Some(owner) => self.owners.insert(window_id, owner),
None => self.owners.remove(&window_id),
};
}
fn clear_window(&mut self, window_id: TeksiloWindowId) {
self.windows.remove(&window_id);
self.owners.remove(&window_id);
let Some(bar) = bar_after_clear(
window_id,
self.key,
self.active,
|window| self.windows.contains_key(&window),
|window| self.owners.get(&window).copied(),
self.app_declared,
) else {
return;
};
match MainThreadMarker::new() {
Some(mtm) => self.show(mtm, bar),
None => self.active = None,
}
}
fn set_app_menu(
&mut self,
menu: NativeMenuSnapshot,
poster: Arc<dyn AppEventPoster>,
declared: bool,
) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
self.app = Some(Self::build(mtm, None, &menu, poster));
self.app_declared = declared;
let key_bar = self.key.map(|key| self.bar_for(key));
if let Some(bar) = bar_after_app_menu(key_bar, self.active) {
self.show(mtm, bar);
}
}
fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
for wm in self.windows.values().chain(self.app.as_ref()) {
let Some(item) = wm.items.get(&id) else {
continue;
};
if let Some(enabled) = delta.enabled {
item.setEnabled(enabled);
}
if let Some(visible) = delta.visible {
item.setHidden(!visible);
}
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());
}
}
}
}
struct BuiltBar {
menu: Retained<NSMenu>,
windows_menu: Retained<NSMenu>,
help_menu: Option<Retained<NSMenu>>,
services_menu: Option<Retained<NSMenu>>,
}
fn build_root_menu(
mtm: MainThreadMarker,
snapshot: &NativeMenuSnapshot,
target: &TeksiloMenuTarget,
items: &mut HashMap<MenuItemId, Retained<NSMenuItem>>,
) -> BuiltBar {
let bar = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), ns_string!(""));
bar.setAutoenablesItems(false);
let plan = mac_bar_plan(snapshot);
let mut windows_menu = None;
let mut help_menu = None;
let mut services_menu = None;
for (index, node) in plan.menus.iter().copied().enumerate() {
match node {
NativeMenuNode::Standard {
role: StandardMenuRole::App,
labels,
quit_item,
settings_item,
} => {
let (item, services) = app_menu_item(
mtm,
labels,
quit_item.as_ref(),
settings_item.as_ref(),
target,
items,
);
bar.addItem(&item);
services_menu = Some(services);
}
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",
NSEventModifierFlags::Command,
));
sub.addItem(&standard_item(
mtm,
&labels.zoom,
sel!(performZoom:),
"",
NSEventModifierFlags::Command,
));
sub.addItem(&NSMenuItem::separatorItem(mtm));
item.setSubmenu(Some(&sub));
bar.addItem(&item);
if plan.windows_menu == Some(index) {
windows_menu = 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);
if plan.help_menu == Some(index) {
help_menu = 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 => {}
}
}
let windows_menu = windows_menu.unwrap_or_else(|| {
let item = top_level_item(mtm, "");
item.setHidden(true);
let sub = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), ns_string!(""));
sub.setAutoenablesItems(false);
let dummy = top_level_item(mtm, "");
dummy.setHidden(true);
sub.addItem(&dummy);
item.setSubmenu(Some(&sub));
bar.addItem(&item);
sub
});
BuiltBar {
menu: bar,
windows_menu,
help_menu,
services_menu,
}
}
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,
visible,
check,
} => {
let item = leaf_item(mtm, title, target);
item.setTag(id.raw() as isize);
item.setEnabled(*enabled);
item.setHidden(!*visible);
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.setAllowsKeyEquivalentWhenHidden(false);
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>, Retained<NSMenu>) {
let bar_item = top_level_item(mtm, &labels.title);
let menu = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), &NSString::from_str(&labels.title));
menu.setAutoenablesItems(false);
let services =
NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), &NSString::from_str(&labels.services));
let command = NSEventModifierFlags::Command;
for row in mac_app_menu_rows(settings_item.is_some()) {
let item = match row {
MacAppMenuRow::About => standard_item(
mtm,
&labels.about,
sel!(orderFrontStandardAboutPanel:),
"",
command,
),
MacAppMenuRow::Settings => match settings_item {
Some(routed) => routed_item(mtm, &labels.settings, routed, target, items),
None => continue,
},
MacAppMenuRow::Services => {
let item = top_level_item(mtm, &labels.services);
item.setSubmenu(Some(&services));
item
}
MacAppMenuRow::Hide => standard_item(mtm, &labels.hide, sel!(hide:), "h", command),
MacAppMenuRow::HideOthers => standard_item(
mtm,
&labels.hide_others,
sel!(hideOtherApplications:),
"h",
command | NSEventModifierFlags::Option,
),
MacAppMenuRow::ShowAll => standard_item(
mtm,
&labels.show_all,
sel!(unhideAllApplications:),
"",
command,
),
MacAppMenuRow::Quit => match quit_item {
None => standard_item(mtm, &labels.quit, sel!(terminate:), "q", command),
Some(routed) => routed_item(mtm, &labels.quit, routed, target, items),
},
MacAppMenuRow::Separator => NSMenuItem::separatorItem(mtm),
};
menu.addItem(&item);
}
bar_item.setSubmenu(Some(&menu));
(bar_item, services)
}
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,
modifiers: NSEventModifierFlags,
) -> 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(modifiers);
}
item
}