Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/platform/mac/menus.rs
1use cocoa::appkit::{NSApp, NSEventModifierFlags, NSMenu, NSMenuItem};
2use cocoa::base::selector;
3use cocoa::{
4 appkit::{
5 NSDownArrowFunctionKey, NSEndFunctionKey, NSF10FunctionKey, NSF11FunctionKey,
6 NSF12FunctionKey, NSF13FunctionKey, NSF14FunctionKey, NSF15FunctionKey, NSF16FunctionKey,
7 NSF17FunctionKey, NSF18FunctionKey, NSF19FunctionKey, NSF1FunctionKey, NSF20FunctionKey,
8 NSF2FunctionKey, NSF3FunctionKey, NSF4FunctionKey, NSF5FunctionKey, NSF6FunctionKey,
9 NSF7FunctionKey, NSF8FunctionKey, NSF9FunctionKey, NSHomeFunctionKey, NSInsertFunctionKey,
10 NSLeftArrowFunctionKey, NSPageDownFunctionKey, NSPageUpFunctionKey,
11 NSRightArrowFunctionKey, NSUpArrowFunctionKey,
12 },
13 base::{id, nil},
14 foundation::{NSArray, NSAutoreleasePool, NSInteger},
15};
16use lazy_static::lazy_static;
17use objc::runtime::{NO, YES};
18use objc::{msg_send, sel, sel_impl};
19use std::{boxed::Box, cell::RefCell, collections::HashMap, ffi::c_void, rc::Rc};
20use strato_ui_core::actions::StandardAction;
21use strato_ui_core::keymap::Keystroke;
22use strato_ui_core::platform::menu::{
23 ItemTriggeredCallback, Menu, MenuBar, MenuItem, MenuItemProperties, MenuItemPropertyChanges,
24 UpdateMenuItemCallback,
25};
26 
27use super::app::callback_dispatcher;
28use super::make_nsstring;
29 
30lazy_static! {
31 /// A mac-menu-specific map of key names to special characters used for the keyboard shortcuts
32 /// in the mac menus
33 static ref MENU_KEY_EQUIVALENTS: HashMap<&'static str, char> = {
34 fn to_char(key: u16) -> char {
35 char::from_u32(key.into()).unwrap()
36 }
37 
38 HashMap::from([
39 ("up", to_char(NSUpArrowFunctionKey)),
40 ("down", to_char(NSDownArrowFunctionKey)),
41 ("left", to_char(NSLeftArrowFunctionKey)),
42 ("right", to_char(NSRightArrowFunctionKey)),
43 ("home", to_char(NSHomeFunctionKey)),
44 ("end", to_char(NSEndFunctionKey)),
45 ("pageup", to_char(NSPageUpFunctionKey)),
46 ("pagedown", to_char(NSPageDownFunctionKey)),
47 ("enter", '\n'),
48 ("tab", '\t'),
49 ("insert", to_char(NSInsertFunctionKey)),
50 ("f1", to_char(NSF1FunctionKey)),
51 ("f2", to_char(NSF2FunctionKey)),
52 ("f3", to_char(NSF3FunctionKey)),
53 ("f4", to_char(NSF4FunctionKey)),
54 ("f5", to_char(NSF5FunctionKey)),
55 ("f6", to_char(NSF6FunctionKey)),
56 ("f7", to_char(NSF7FunctionKey)),
57 ("f8", to_char(NSF8FunctionKey)),
58 ("f9", to_char(NSF9FunctionKey)),
59 ("f10", to_char(NSF10FunctionKey)),
60 ("f11", to_char(NSF11FunctionKey)),
61 ("f12", to_char(NSF12FunctionKey)),
62 ("f13", to_char(NSF13FunctionKey)),
63 ("f14", to_char(NSF14FunctionKey)),
64 ("f15", to_char(NSF15FunctionKey)),
65 ("f16", to_char(NSF16FunctionKey)),
66 ("f17", to_char(NSF17FunctionKey)),
67 ("f18", to_char(NSF18FunctionKey)),
68 ("f19", to_char(NSF19FunctionKey)),
69 ("f20", to_char(NSF20FunctionKey)),
70 // The following values are the inverse of `ui/src/platform/mac/event.rs` mappings
71 ("numpadenter", to_char(0x03)),
72 ("escape", to_char(0x1b)),
73 // Note: Backspace and Delete have different characters for the menu key equivalents
74 // than they send when they are pressed. See the discussion in the Apple docs:
75 // https://developer.apple.com/documentation/appkit/nsmenuitem/1514842-keyequivalent?language=objc
76 ("backspace", to_char(0x08)),
77 ("delete", to_char(0x7F)),
78 ])
79 };
80}
81 
82/// Data associated with a custom NSMenuItem.
83struct MenuItemData {
84 /// Properties of the menu item.
85 /// These could be computed from the menu item but we trust AppKit does not change them.
86 props: RefCell<MenuItemProperties>,
87 
88 /// Callback when the menu item is triggered by the user.
89 triggered: ItemTriggeredCallback,
90 
91 /// Callback when the menu item needs updating.
92 update: UpdateMenuItemCallback,
93}
94 
95impl MenuItemData {
96 /// Convert self to a Cocoa context pointer, including the refcount.
97 /// This should be balanced by consume_cocoa_context.
98 fn into_context(self: Rc<MenuItemData>) -> *mut c_void {
99 Box::into_raw(Box::new(self)) as *mut c_void
100 }
101 
102 /// Read out from the Cocoa context pointer, without consuming its refcount.
103 fn read_context(ctx: *const c_void) -> Rc<MenuItemData> {
104 unsafe {
105 let ptr = &*(ctx as *const Rc<MenuItemData>);
106 ptr.clone()
107 }
108 }
109 
110 /// Balances a call from to_cocoa_context.
111 fn consume_context(ctx: *mut c_void) {
112 unsafe { std::mem::drop(Box::from_raw(ctx as *mut Rc<MenuItemData>)) }
113 }
114}
115 
116/// We hand Cocoa a void* which is really an unwrapped Box<Rc<MenuItemData>>.
117/// The NSMenuItem logically holds a reference count on this Rc, which is balanced in our dealloc callback below.
118/// The following functions are invoked from Cocoa.
119#[no_mangle]
120extern "C-unwind" fn warp_menu_item_needs_update(item: id, ctx: *mut c_void) {
121 let ctx = MenuItemData::read_context(ctx);
122 let props: MenuItemProperties = ctx.props.borrow().clone();
123 let func = &ctx.update;
124 
125 let mut updated_properties = callback_dispatcher().update_menu_item(|ctx| func(&props, ctx));
126 
127 // Always re-apply the disabled state even when the updater has no opinion.
128 // AppKit's modal sessions (e.g. [NSAlert runModal]) can externally disable
129 // menu items, and items whose updaters return `disabled: None` would never
130 // call setEnabled: to restore the correct state. On macOS with the quake
131 // mode (non-activating panel) window, this results in permanently disabled
132 // items after a modal is dismissed. Default to enabled — updaters that want
133 // an item disabled must say so explicitly.
134 if updated_properties.disabled.is_none() {
135 updated_properties.disabled = Some(false);
136 }
137 
138 // Update any changed properties.
139 ctx.props.borrow_mut().apply(&updated_properties);
140 unsafe { apply_changes(updated_properties, item) };
141}
142 
143#[no_mangle]
144extern "C-unwind" fn warp_menu_item_triggered(_item: id, ctx: *mut c_void) {
145 let func = &MenuItemData::read_context(ctx).triggered;
146 callback_dispatcher().menu_item_triggered(func);
147}
148 
149#[no_mangle]
150extern "C-unwind" fn warp_menu_item_deallocated(ctx: *mut c_void) {
151 MenuItemData::consume_context(ctx)
152}
153 
154// Declarations of functions implemented in ObjC files.
155// These signatures must be manually synced - there's no type checking here.
156extern "C" {
157 fn make_delegated_menu(title: id) -> id;
158 fn make_warp_custom_menu_item(ctx: *mut c_void) -> id;
159 fn set_menu_item_submenu(item: id, submenu: id);
160 fn make_services_menu_item() -> id;
161}
162 
163struct StandardMenuItemProperties {
164 title: &'static str, // menu item title
165 action: &'static str, // the selector name
166 shortcut: &'static str, // the key equivalent string, or empty for none
167 modifiers: NSEventModifierFlags,
168}
169 
170// Get properties from a standard action.
171fn resolve_standard_action(action: StandardAction) -> StandardMenuItemProperties {
172 let cmd = NSEventModifierFlags::NSCommandKeyMask;
173 let option = NSEventModifierFlags::NSAlternateKeyMask;
174 let ctrl = NSEventModifierFlags::NSControlKeyMask;
175 let none = NSEventModifierFlags::empty();
176 
177 fn make(
178 title: &'static str,
179 action: &'static str,
180 modifiers: NSEventModifierFlags,
181 shortcut: &'static str,
182 ) -> StandardMenuItemProperties {
183 StandardMenuItemProperties {
184 title,
185 action,
186 shortcut,
187 modifiers,
188 }
189 }
190 
191 match action {
192 StandardAction::Close => make("Close Window", "performClose:", none, ""),
193 StandardAction::Quit => make("Quit Warp", "terminate:", cmd, "q"),
194 StandardAction::Hide => make("Hide Warp", "hide:", cmd, "h"),
195 StandardAction::HideOtherApps => {
196 make("Hide Others", "hideOtherApplications:", cmd | option, "h")
197 }
198 StandardAction::ShowAllApps => make("Show All", "unhideAllApplications:", none, ""),
199 StandardAction::Minimize => make("Minimize", "performMiniaturize:", cmd, "m"),
200 StandardAction::Zoom => make("Zoom", "performZoom:", none, ""),
201 StandardAction::BringAllToFront => make("Bring All to Front", "arrangeInFront:", none, ""),
202 StandardAction::ToggleFullScreen => {
203 make("ToggleFullScreen", "toggleFullScreen:", cmd | ctrl, "f")
204 }
205 StandardAction::Paste => make("Paste", "paste:", none, ""),
206 }
207}
208 
209/// Determine the key equivalent for the given keystroke
210fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (id, NSEventModifierFlags) {
211 let mut flags = NSEventModifierFlags::empty();
212 
213 let keystroke = match keystroke {
214 Some(value) => value,
215 None => return (make_nsstring(""), flags),
216 };
217 
218 let key_equivalent = match MENU_KEY_EQUIVALENTS.get(keystroke.key.as_str()) {
219 Some(c) => make_nsstring(String::from(*c)),
220 None => make_nsstring(&keystroke.key),
221 };
222 
223 for (is_set, flag) in [
224 (keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask),
225 (keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask),
226 (keystroke.shift, NSEventModifierFlags::NSShiftKeyMask),
227 (keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask),
228 ] {
229 if is_set {
230 flags |= flag
231 }
232 }
233 
234 (key_equivalent, flags)
235}
236 
237// Apply any differences between the two states to the menu item.
238unsafe fn apply_changes(changes: MenuItemPropertyChanges, item: id) {
239 // Wrap in a local autorelease pool: AppKit invokes `warp_menu_item_needs_update`
240 // on every menu validation (per menu open and per keystroke for shortcut matching),
241 // so this is a hot path. A local pool bounds peak memory for the NSString temporaries
242 // created here (item title, key equivalent) without relying on the outer AppKit pool.
243 let pool = NSAutoreleasePool::new(nil);
244 if let Some(name) = changes.name {
245 let _: () = msg_send![item, setTitle: make_nsstring(name)];
246 }
247 if let Some(keystroke) = changes.keystroke {
248 let (key_equivalent, modifiers) = resolve_key_equivalent(keystroke.as_ref());
249 let _: () = msg_send![item, setKeyEquivalent: key_equivalent];
250 let _: () = msg_send![item, setKeyEquivalentModifierMask: modifiers];
251 }
252 if let Some(disabled) = changes.disabled {
253 let enabled = if disabled { NO } else { YES };
254 let _: () = msg_send![item, setEnabled: enabled];
255 }
256 if let Some(checked) = changes.checked {
257 // NSControlStateValue has Off as 0, On as 1, Mixed as -1.
258 let control_state: NSInteger = i64::from(checked);
259 let _: () = msg_send![item, setState: control_state];
260 }
261 if let Some(submenu) = changes.submenu {
262 let nsmenu = submenu
263 .map(|menu_items| make_submenu(menu_items))
264 .unwrap_or(nil);
265 set_menu_item_submenu(item, nsmenu);
266 }
267 pool.drain();
268}
269 
270unsafe fn make_submenu(menu_items: Vec<MenuItem>) -> id {
271 let nsmenu = make_delegated_menu(make_nsstring(""));
272 for menu_item in menu_items {
273 nsmenu.addItem_(make_menu_item(menu_item));
274 }
275 nsmenu
276}
277 
278unsafe fn make_menu_item(menu_item: MenuItem) -> id {
279 match menu_item {
280 MenuItem::Custom(custom_menu_item) => {
281 let props = custom_menu_item.properties;
282 let data = Rc::new(MenuItemData {
283 props: RefCell::new(props.clone()),
284 triggered: custom_menu_item.callback,
285 update: custom_menu_item.updater,
286 });
287 
288 let nsmenu_item = make_warp_custom_menu_item(MenuItemData::into_context(data));
289 
290 // Set initial properties for the item.
291 apply_changes(
292 MenuItemPropertyChanges::for_new_item(props, custom_menu_item.submenu),
293 nsmenu_item,
294 );
295 
296 nsmenu_item
297 }
298 MenuItem::Standard(standard_action) => {
299 let properties = resolve_standard_action(standard_action);
300 let nsmenu_item = NSMenuItem::alloc(nil)
301 .initWithTitle_action_keyEquivalent_(
302 make_nsstring(properties.title),
303 selector(properties.action),
304 make_nsstring(properties.shortcut),
305 )
306 .autorelease();
307 nsmenu_item.setKeyEquivalentModifierMask_(properties.modifiers);
308 let _: id = msg_send![nsmenu_item, setTag: standard_action as libc::c_long];
309 nsmenu_item
310 }
311 MenuItem::Separator => NSMenuItem::separatorItem(nil),
312 MenuItem::Services => make_services_menu_item(),
313 }
314}
315 
316/// \return an autoreleased NSMenuItem with a submenu represented by \p menu.
317// This supports creating the top-level menu bar.
318unsafe fn make_top_level_menu_item(menu: Menu) -> id {
319 let nsmenu = make_delegated_menu(make_nsstring(&menu.title));
320 
321 if menu.is_window_menu() {
322 // `setWindowsMenu` gives us all the default window menu items like
323 // 'Enter Full Screen' and 'Tile Window to Left of Screen'.
324 let () = msg_send![NSApp(), setWindowsMenu: nsmenu];
325 }
326 
327 for menu_item in menu.menu_items {
328 nsmenu.addItem_(make_menu_item(menu_item));
329 }
330 
331 let menuitem = NSMenuItem::alloc(nil).init().autorelease();
332 menuitem.setSubmenu_(nsmenu);
333 menuitem
334}
335 
336/// \return an autoreleased NSMenu representing the given menu bar.
337pub unsafe fn make_main_menu(menubar: MenuBar) -> id {
338 let main_menu = NSMenu::alloc(nil).init().autorelease();
339 for menu in menubar.menus {
340 main_menu.addItem_(make_top_level_menu_item(menu));
341 }
342 main_menu
343}
344 
345/// \return an autoreleased NSMenu representing the given dock menu.
346pub unsafe fn make_dock_menu(menu: Menu) -> id {
347 let dock_menu = NSMenu::alloc(nil).init().autorelease();
348 for item in menu.menu_items {
349 dock_menu.addItem_(make_menu_item(item));
350 }
351 dock_menu
352}
353 
354#[cfg(test)]
355#[path = "menus_tests.rs"]
356mod tests;
357