use std::cell::{Cell, RefCell};
use kui_core::{MenuBar, MenuItem, MenuRole, Vec2};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Sel};
use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{
NSApplication, NSAttributedStringNSStringDrawing, NSColor, NSEventModifierFlags, NSFont,
NSFontAttributeName, NSForegroundColorAttributeName, NSMenu, NSMenuItem,
NSMutableParagraphStyle, NSParagraphStyleAttributeName, NSTextAlignment, NSTextTab, NSView,
};
use objc2_foundation::{
MainThreadMarker, NSArray, NSAttributedString, NSDictionary, NSMutableAttributedString,
NSObject, NSObjectProtocol, NSPoint, NSRange, NSString, ns_string,
};
use winit::window::Window;
struct Pending {
view: Retained<NSView>,
at: Vec2,
items: Vec<MenuItem>,
}
struct TargetIvars {
pending: RefCell<Option<Pending>>,
chosen: Cell<isize>,
up: Cell<bool>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "KuiMenuTarget"]
#[ivars = TargetIvars]
struct MenuTarget;
impl MenuTarget {
#[unsafe(method(kuiMenuPick:))]
fn pick(&self, sender: &NSMenuItem) {
self.ivars().chosen.set(sender.tag());
}
#[unsafe(method(kuiMenuShow))]
fn show(&self) {
let Some(p) = self.ivars().pending.borrow_mut().take() else {
return;
};
let mtm = MainThreadMarker::from(self);
let menu = build(mtm, self, &p.items);
self.ivars().chosen.set(-1);
let at = NSPoint::new(p.at.x as f64, p.at.y as f64);
self.ivars().up.set(true);
menu.popUpMenuPositioningItem_atLocation_inView(None, at, Some(&p.view));
self.ivars().up.set(false);
}
}
unsafe impl NSObjectProtocol for MenuTarget {}
);
impl MenuTarget {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(TargetIvars {
pending: RefCell::new(None),
chosen: Cell::new(-1),
up: Cell::new(false),
});
unsafe { msg_send![super(this), init] }
}
}
fn build(mtm: MainThreadMarker, target: &MenuTarget, items: &[MenuItem]) -> Retained<NSMenu> {
let menu = NSMenu::new(mtm);
menu.setAutoenablesItems(false);
let hints = HintTab::of(items);
for (i, item) in items.iter().enumerate() {
if item.role == MenuRole::Separator {
menu.addItem(&NSMenuItem::separatorItem(mtm));
continue;
}
let target = unsafe { &*(target as *const MenuTarget as *const AnyObject) };
menu.addItem(&menu_row(mtm, item, target, sel!(kuiMenuPick:), i, hints));
}
menu
}
fn hint(item: &MenuItem) -> Option<&str> {
let text = item.accel_text().filter(|t| !t.is_empty())?;
let bound = kui_core::Accel::parse(text)
.filter(|a| a.mods.any())
.and_then(|a| a.key_equivalent())
.is_some_and(|k| !k.is_empty());
(!bound).then_some(text)
}
#[derive(Clone, Copy)]
struct HintTab(f64);
impl HintTab {
const GAP: f64 = 24.0;
fn of(items: &[MenuItem]) -> Option<Self> {
let font = NSFont::menuFontOfSize(0.0);
let (mut title, mut hinted) = (0f64, None::<f64>);
for item in items.iter().filter(|i| i.role != MenuRole::Separator) {
title = title.max(text_width(item.text(), &font));
if let Some(h) = hint(item) {
hinted = Some(hinted.unwrap_or(0.0).max(text_width(h, &font)));
}
}
hinted.map(|h| Self(title + Self::GAP + h))
}
}
fn text_width(text: &str, font: &NSFont) -> f64 {
let s = NSMutableAttributedString::from_nsstring(&NSString::from_str(text));
let all = NSRange::new(0, s.length());
unsafe { s.addAttribute_value_range(NSFontAttributeName, font, all) };
s.size().width
}
fn hinted_title(
title: &str,
hint: &str,
tab: HintTab,
enabled: bool,
) -> Retained<NSAttributedString> {
let s =
NSMutableAttributedString::from_nsstring(&NSString::from_str(&format!("{title}\t{hint}")));
let all = NSRange::new(0, s.length());
let title_len = title.encode_utf16().count();
let hint_range = NSRange::new(title_len + 1, s.length() - title_len - 1);
let style = NSMutableParagraphStyle::new();
let stop = unsafe {
NSTextTab::initWithTextAlignment_location_options(
NSTextTab::alloc(),
NSTextAlignment::Right,
tab.0,
&NSDictionary::new(),
)
};
style.setTabStops(Some(&NSArray::from_retained_slice(&[stop])));
unsafe {
s.addAttribute_value_range(NSFontAttributeName, &NSFont::menuFontOfSize(0.0), all);
s.addAttribute_value_range(NSParagraphStyleAttributeName, &style, all);
if enabled {
s.addAttribute_value_range(
NSForegroundColorAttributeName,
&NSColor::secondaryLabelColor(),
hint_range,
);
} else {
s.addAttribute_value_range(
NSForegroundColorAttributeName,
&NSColor::disabledControlTextColor(),
all,
);
}
}
Retained::into_super(s)
}
fn menu_row(
mtm: MainThreadMarker,
item: &MenuItem,
target: &AnyObject,
action: Sel,
tag: usize,
hints: Option<HintTab>,
) -> Retained<NSMenuItem> {
let accel = item
.accel_text()
.and_then(kui_core::Accel::parse)
.filter(|a| a.mods.any());
let key = accel
.as_ref()
.and_then(|a| a.key_equivalent())
.unwrap_or_default();
let title = NSString::from_str(item.text());
let equiv = NSString::from_str(&key);
let row = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&title,
Some(action),
&equiv,
)
};
unsafe {
row.setTarget(Some(target));
row.setTag(tag as isize);
if let (Some(tab), Some(h)) = (hints, hint(item)) {
row.setAttributedTitle(Some(&hinted_title(item.text(), h, tab, item.enabled)));
}
row.setEnabled(item.enabled);
row.setState(objc2_app_kit::NSControlStateValue::from(if item.checked {
1isize
} else {
0
}));
if let Some(a) = accel.filter(|_| !key.is_empty()) {
let mut flags = NSEventModifierFlags::empty();
if a.mods.super_key {
flags |= NSEventModifierFlags::Command;
}
if a.mods.shift {
flags |= NSEventModifierFlags::Shift;
}
if a.mods.alt {
flags |= NSEventModifierFlags::Option;
}
if a.mods.ctrl {
flags |= NSEventModifierFlags::Control;
}
row.setKeyEquivalentModifierMask(flags);
}
}
row
}
pub fn show_definition(window: &Window, at: kui_core::Vec2, text: &str) -> bool {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
if text.is_empty() {
return false;
}
let Ok(handle) = window.window_handle() else {
return false;
};
let RawWindowHandle::AppKit(h) = handle.as_raw() else {
return false;
};
let Some(view) = (unsafe { Retained::retain(h.ns_view.as_ptr().cast::<NSView>()) }) else {
return false;
};
let s = NSAttributedString::from_nsstring(&NSString::from_str(text));
let at = NSPoint::new(at.x as f64, at.y as f64);
view.showDefinitionForAttributedString_atPoint(Some(&s), at);
true
}
fn responder_row(
mtm: MainThreadMarker,
title: &str,
action: Sel,
key: &str,
mods: Option<NSEventModifierFlags>,
) -> Retained<NSMenuItem> {
let row = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(title),
Some(action),
&NSString::from_str(key),
)
};
if let Some(mods) = mods {
row.setKeyEquivalentModifierMask(mods);
}
row
}
fn trim_edit(menu: &NSMenu, own: isize) {
let kept = |item: &NSMenuItem| {
item.action()
.is_some_and(|a| a == sel!(orderFrontCharacterPalette:) || a == sel!(startDictation:))
};
let mut i = own;
while i < menu.numberOfItems() {
let item = menu.itemAtIndex(i).expect("counted");
if item.isSeparatorItem() || kept(&item) {
i += 1;
} else {
menu.removeItemAtIndex(i);
}
}
if menu.numberOfItems() == own + 1
&& menu.itemAtIndex(own).is_some_and(|it| it.isSeparatorItem())
{
menu.removeItemAtIndex(own);
}
}
fn full_screen_rows(mtm: MainThreadMarker, menu: &NSMenu) {
menu.addItem(&responder_row(
mtm,
"Enter Full Screen",
sel!(toggleFullScreen:),
"f",
Some(NSEventModifierFlags::Function),
));
let twin = responder_row(
mtm,
"Enter Full Screen",
sel!(toggleFullScreen:),
"f",
Some(NSEventModifierFlags::Control | NSEventModifierFlags::Command),
);
twin.setHidden(true);
menu.addItem(&twin);
}
pub fn exclude_from_windows_menu(window: &Window) {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Ok(handle) = window.window_handle() else {
return;
};
let RawWindowHandle::AppKit(h) = handle.as_raw() else {
return;
};
let view = unsafe { Retained::retain(h.ns_view.as_ptr().cast::<NSView>()) };
if let Some(ns_window) = view.and_then(|v| v.window()) {
ns_window.setExcludedFromWindowsMenu(true);
}
}
pub struct MacMenu {
target: Retained<MenuTarget>,
}
impl MacMenu {
pub fn new() -> Option<Self> {
let mtm = MainThreadMarker::new()?;
Some(Self {
target: MenuTarget::new(mtm),
})
}
pub fn present(&self, window: &Window, at: Vec2, items: &[MenuItem]) -> bool {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Ok(handle) = window.window_handle() else {
return false;
};
let RawWindowHandle::AppKit(h) = handle.as_raw() else {
return false;
};
let view: Retained<NSView> =
unsafe { Retained::retain(h.ns_view.as_ptr().cast::<NSView>()) }.expect("live view");
*self.target.ivars().pending.borrow_mut() = Some(Pending {
view,
at,
items: items.to_vec(),
});
self.target.ivars().chosen.set(-1);
let sel: Sel = sel!(kuiMenuShow);
unsafe {
let _: () = msg_send![&*self.target, performSelector: sel, withObject: std::ptr::null::<AnyObject>(), afterDelay: 0.0f64];
}
true
}
pub fn take_chosen(&self) -> Option<usize> {
let chosen = self.target.ivars().chosen.replace(-1);
(chosen >= 0).then_some(chosen as usize)
}
pub fn busy(&self) -> bool {
self.target.ivars().up.get() || self.target.ivars().pending.borrow().is_some()
}
}
struct BarIvars {
chosen: Cell<isize>,
waker: RefCell<Option<crate::Waker>>,
edit_state: Cell<u8>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "KuiMenuBarTarget"]
#[ivars = BarIvars]
struct BarTarget;
impl BarTarget {
#[unsafe(method(kuiBarPick:))]
fn pick(&self, sender: &NSMenuItem) {
self.ivars().chosen.set(sender.tag());
if let Some(waker) = self.ivars().waker.borrow().as_ref() {
waker.wake();
}
}
#[unsafe(method(validateMenuItem:))]
fn validate(&self, item: &NSMenuItem) -> objc2::runtime::Bool {
let tag = item.tag();
if tag < CHORD_TAG {
return objc2::runtime::Bool::new(item.isEnabled());
}
let state = EditState::from_bits(self.ivars().edit_state.get());
let on = EDIT_ROWS
.get((tag - CHORD_TAG) as usize)
.copied()
.flatten()
.is_some_and(|(_, letter, shift)| state.applies(letter, shift));
objc2::runtime::Bool::new(on)
}
}
unsafe impl NSObjectProtocol for BarTarget {}
);
impl BarTarget {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(BarIvars {
chosen: Cell::new(-1),
waker: RefCell::new(None),
edit_state: Cell::new(0),
});
unsafe { msg_send![super(this), init] }
}
}
pub struct MacMenuBar {
target: Retained<BarTarget>,
map: RefCell<Vec<(usize, usize)>>,
standard: RefCell<Option<Standard>>,
}
struct Standard {
root: Retained<NSMenu>,
window: Retained<NSMenu>,
edit: (Retained<NSMenu>, isize),
}
const CHORD_TAG: isize = 1 << 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EditChord {
pub letter: char,
pub shift: bool,
}
const EDIT_ROWS: [Option<(&str, char, bool)>; 7] = [
Some(("Undo", 'z', false)),
Some(("Redo", 'z', true)),
None,
Some(("Cut", 'x', false)),
Some(("Copy", 'c', false)),
Some(("Paste", 'v', false)),
Some(("Select All", 'a', false)),
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EditState {
pub undo: bool,
pub redo: bool,
pub cut: bool,
pub copy: bool,
pub paste: bool,
pub select_all: bool,
}
impl EditState {
fn bits(self) -> u8 {
(self.undo as u8)
| (self.redo as u8) << 1
| (self.cut as u8) << 2
| (self.copy as u8) << 3
| (self.paste as u8) << 4
| (self.select_all as u8) << 5
}
fn from_bits(b: u8) -> Self {
Self {
undo: b & 1 != 0,
redo: b & 2 != 0,
cut: b & 4 != 0,
copy: b & 8 != 0,
paste: b & 16 != 0,
select_all: b & 32 != 0,
}
}
fn applies(self, letter: char, shift: bool) -> bool {
match (letter, shift) {
('z', false) => self.undo,
('z', true) => self.redo,
('x', _) => self.cut,
('c', _) => self.copy,
('v', _) => self.paste,
('a', _) => self.select_all,
_ => true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BarPick {
Item(usize, usize),
Chord(EditChord),
}
impl MacMenuBar {
pub fn new() -> Option<Self> {
let mtm = MainThreadMarker::new()?;
Some(Self {
target: BarTarget::new(mtm),
map: RefCell::new(Vec::new()),
standard: RefCell::new(None),
})
}
pub fn set_waker(&self, waker: crate::Waker) {
*self.target.ivars().waker.borrow_mut() = Some(waker);
}
pub fn apply(&self, bar: &MenuBar) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
self.standard(mtm, &app);
if bar.is_empty() {
self.map.borrow_mut().clear();
let standard = self.standard.borrow();
let standard = standard.as_ref().expect("built above");
app.setWindowsMenu(Some(&standard.window));
app.setMainMenu(Some(&standard.root));
trim_edit(&standard.edit.0, standard.edit.1);
return;
}
let mut map = Vec::new();
let mut windows_menu = None;
let mut edit_menus = Vec::new();
let root = NSMenu::new(mtm);
root.setAutoenablesItems(false);
for (mi, menu) in bar.menus.iter().enumerate() {
let title = NSString::from_str(&menu.label);
let head = NSMenuItem::new(mtm);
head.setTitle(&title);
head.setEnabled(menu.enabled);
let sub = NSMenu::initWithTitle(NSMenu::alloc(mtm), &title);
sub.setAutoenablesItems(false);
let hints = HintTab::of(&menu.items);
for (ii, item) in menu.items.iter().enumerate() {
if item.role == MenuRole::Separator {
sub.addItem(&NSMenuItem::separatorItem(mtm));
continue;
}
let tag = map.len();
map.push((mi, ii));
sub.addItem(&self.row(mtm, item, tag, hints));
}
if menu.label == "Edit" {
edit_menus.push((sub.clone(), sub.numberOfItems()));
}
if menu.label == "Window" {
full_screen_rows(mtm, &sub);
sub.setAutoenablesItems(true);
windows_menu = Some(sub.clone());
}
head.setSubmenu(Some(&sub));
root.addItem(&head);
}
*self.map.borrow_mut() = map;
app.setWindowsMenu(windows_menu.as_deref());
app.setMainMenu(Some(&root));
for (menu, own) in &edit_menus {
trim_edit(menu, *own);
}
}
fn standard(&self, mtm: MainThreadMarker, app: &NSApplication) {
if self.standard.borrow().is_some() {
return;
}
let root = NSMenu::new(mtm);
let app_head = app
.mainMenu()
.filter(|m| m.numberOfItems() > 0)
.map(|m| {
let head = m.itemAtIndex(0).expect("counted");
m.removeItemAtIndex(0);
head
})
.unwrap_or_else(|| {
let app_menu = NSMenu::new(mtm);
let quit = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
ns_string!("Quit"),
Some(sel!(terminate:)),
ns_string!("q"),
)
};
app_menu.addItem(&quit);
let head = NSMenuItem::new(mtm);
head.setSubmenu(Some(&app_menu));
head
});
root.addItem(&app_head);
let edit = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!("Edit"));
let target = unsafe { &*(&*self.target as *const BarTarget as *const AnyObject) };
for (i, row) in EDIT_ROWS.iter().enumerate() {
let Some((title, letter, shift)) = row else {
edit.addItem(&NSMenuItem::separatorItem(mtm));
continue;
};
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(title),
Some(sel!(kuiBarPick:)),
&NSString::from_str(&letter.to_string()),
)
};
let mut flags = NSEventModifierFlags::Command;
if *shift {
flags |= NSEventModifierFlags::Shift;
}
unsafe {
item.setTarget(Some(target));
item.setKeyEquivalentModifierMask(flags);
item.setTag(CHORD_TAG + i as isize);
}
edit.addItem(&item);
}
let edit_own = edit.numberOfItems();
let edit_head = NSMenuItem::new(mtm);
edit_head.setTitle(ns_string!("Edit"));
edit_head.setSubmenu(Some(&edit));
root.addItem(&edit_head);
let window = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!("Window"));
window.addItem(&responder_row(
mtm,
"Minimize",
sel!(performMiniaturize:),
"m",
None,
));
window.addItem(&responder_row(mtm, "Zoom", sel!(performZoom:), "", None));
full_screen_rows(mtm, &window);
window.addItem(&NSMenuItem::separatorItem(mtm));
window.addItem(&responder_row(
mtm,
"Bring All to Front",
sel!(arrangeInFront:),
"",
None,
));
let window_head = NSMenuItem::new(mtm);
window_head.setTitle(ns_string!("Window"));
window_head.setSubmenu(Some(&window));
root.addItem(&window_head);
*self.standard.borrow_mut() = Some(Standard {
root,
window,
edit: (edit, edit_own),
});
}
fn row(
&self,
mtm: MainThreadMarker,
item: &MenuItem,
tag: usize,
hints: Option<HintTab>,
) -> Retained<NSMenuItem> {
let target = unsafe { &*(&*self.target as *const BarTarget as *const AnyObject) };
menu_row(mtm, item, target, sel!(kuiBarPick:), tag, hints)
}
pub fn set_edit_state(&self, state: EditState) {
self.target.ivars().edit_state.set(state.bits());
}
pub fn take_chosen(&self) -> Option<BarPick> {
let tag = self.target.ivars().chosen.replace(-1);
if tag < 0 {
return None;
}
if tag >= CHORD_TAG {
let (_, letter, shift) = EDIT_ROWS
.get((tag - CHORD_TAG) as usize)
.copied()
.flatten()?;
return Some(BarPick::Chord(EditChord { letter, shift }));
}
let (menu, item) = self.map.borrow().get(tag as usize).copied()?;
Some(BarPick::Item(menu, item))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hint_is_what_binds_nothing() {
let row = |accel: &str| MenuItem::new("Row").accel(accel);
assert_eq!(hint(&row("gd")), Some("gd"));
assert_eq!(hint(&row("space")), Some("space"));
assert_eq!(hint(&row("mod+s")), None);
assert_eq!(hint(&MenuItem::role(MenuRole::Copy)), None);
assert_eq!(hint(&row("")), None);
assert_eq!(hint(&MenuItem::new("Row")), None);
}
#[test]
fn a_hinted_title_is_the_title_a_tab_and_the_hint() {
let t = hinted_title("Go to Definition", "gd", HintTab(200.0), true);
assert_eq!(t.string().to_string(), "Go to Definition\tgd");
let t = hinted_title("Rename…", "grn", HintTab(200.0), false);
assert_eq!(t.string().to_string(), "Rename…\tgrn");
}
}