//! Safe macOS menu adapter over the shared desktop action registry. use muda::{ Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, accelerator::{Accelerator, Code, Modifiers}, }; use crate::action::{self, ActionContext, MenuGroup, UiAction}; pub struct NativeMenu { menu: Menu, window_menu: Submenu, help_menu: Submenu, items: Vec<(UiAction, MenuItem)>, } impl NativeMenu { pub fn install(context: ActionContext) -> Result { let menu = Menu::new(); let mut items = Vec::new(); let mut window_menu = None; let mut help_menu = None; for group in MenuGroup::ALL { let submenu = Submenu::new(group.label(), true); match group { MenuGroup::App => { append_action(&submenu, UiAction::About, context, &mut items)?; submenu.append(&PredefinedMenuItem::separator())?; append_action(&submenu, UiAction::Settings, context, &mut items)?; submenu.append(&PredefinedMenuItem::separator())?; submenu.append(&PredefinedMenuItem::services(None))?; submenu.append(&PredefinedMenuItem::separator())?; submenu.append(&PredefinedMenuItem::hide(None))?; submenu.append(&PredefinedMenuItem::hide_others(None))?; submenu.append(&PredefinedMenuItem::show_all(None))?; submenu.append(&PredefinedMenuItem::separator())?; append_action(&submenu, UiAction::Quit, context, &mut items)?; } MenuGroup::Window => { submenu.append(&PredefinedMenuItem::minimize(None))?; submenu.append(&PredefinedMenuItem::bring_all_to_front(None))?; } MenuGroup::Edit => { submenu.append(&PredefinedMenuItem::undo(None))?; submenu.append(&PredefinedMenuItem::redo(None))?; submenu.append(&PredefinedMenuItem::separator())?; submenu.append(&PredefinedMenuItem::cut(None))?; submenu.append(&PredefinedMenuItem::copy(None))?; submenu.append(&PredefinedMenuItem::paste(None))?; submenu.append(&PredefinedMenuItem::select_all(None))?; submenu.append(&PredefinedMenuItem::separator())?; append_action(&submenu, UiAction::Find, context, &mut items)?; append_action(&submenu, UiAction::SearchContents, context, &mut items)?; } _ => append_actions(&submenu, group, context, &mut items)?, } if group == MenuGroup::Window { window_menu = Some(submenu.clone()); } else if group == MenuGroup::Help { help_menu = Some(submenu.clone()); } menu.append(&submenu)?; } let native = Self { menu, window_menu: window_menu.expect("the Window menu is registered"), help_menu: help_menu.expect("the Help menu is registered"), items, }; native.activate(); Ok(native) } pub fn activate(&self) { self.menu.init_for_nsapp(); self.window_menu.set_as_windows_menu_for_nsapp(); self.help_menu.set_as_help_menu_for_nsapp(); } pub fn sync(&self, context: ActionContext) { for (action, item) in &self.items { item.set_enabled(action::enabled(*action, context)); } } pub fn poll(&self) -> Option { let event = MenuEvent::receiver().try_recv().ok()?; self.items .iter() .find_map(|(action, item)| (item.id() == &event.id).then_some(*action)) } } fn append_actions( submenu: &Submenu, group: MenuGroup, context: ActionContext, items: &mut Vec<(UiAction, MenuItem)>, ) -> Result<(), muda::Error> { for spec in action::actions_in(group).filter(|spec| { !matches!( spec.action, UiAction::About | UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::CopyEditedField | UiAction::Paste | UiAction::Minimize | UiAction::TogglePaneFocus ) }) { append_action(submenu, spec.action, context, items)?; } Ok(()) } fn append_action( submenu: &Submenu, action: UiAction, context: ActionContext, items: &mut Vec<(UiAction, MenuItem)>, ) -> Result<(), muda::Error> { let spec = action::spec_for(action); let item = MenuItem::with_id( format!("ironstorage.{}", action.id()), spec.label, action::enabled(action, context), accelerator(action), ); submenu.append(&item)?; items.push((action, item)); Ok(()) } fn accelerator(action: UiAction) -> Option { let command = Modifiers::SUPER; let (modifiers, code) = match action { UiAction::Settings => (command, Code::Comma), UiAction::NewEntry => (command, Code::KeyN), UiAction::NewFolder => (command | Modifiers::SHIFT, Code::KeyN), UiAction::OpenFolder => (command, Code::KeyO), UiAction::Save => (command, Code::KeyS), UiAction::CloseWindow => (command, Code::KeyW), UiAction::Quit => (command, Code::KeyQ), UiAction::Undo => (command, Code::KeyZ), UiAction::Redo => (command | Modifiers::SHIFT, Code::KeyZ), UiAction::Cut => (command, Code::KeyX), UiAction::Paste => (command, Code::KeyV), UiAction::Find => (command, Code::KeyF), UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF), UiAction::CommandPalette => (command, Code::KeyK), UiAction::Refresh => (command, Code::KeyR), UiAction::ReloadEntry => (command | Modifiers::SHIFT, Code::KeyR), UiAction::EditEntry => (command, Code::KeyE), UiAction::Lock => (command, Code::KeyL), UiAction::Help => (Modifiers::empty(), Code::F1), UiAction::About | UiAction::InitializeStore | UiAction::CopyField | UiAction::CopyEditedField | UiAction::TogglePaneFocus | UiAction::OpenEntry | UiAction::GeneratePassword | UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry | UiAction::GenerateOtp | UiAction::CopyOtp | UiAction::ImportOtp | UiAction::ImportKdbx | UiAction::ShowOtpUri | UiAction::CopyOtpUri | UiAction::ShowOtpQr | UiAction::RemoveOtp | UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync | UiAction::Minimize => return None, }; Some(Accelerator::new(Some(modifiers), code)) } #[cfg(test)] mod tests { use super::*; #[test] fn native_menu_uses_standard_macos_accelerators() { for (action, modifiers, code) in [ (UiAction::Settings, Modifiers::SUPER, Code::Comma), (UiAction::NewEntry, Modifiers::SUPER, Code::KeyN), (UiAction::OpenFolder, Modifiers::SUPER, Code::KeyO), (UiAction::Save, Modifiers::SUPER, Code::KeyS), (UiAction::CloseWindow, Modifiers::SUPER, Code::KeyW), (UiAction::Quit, Modifiers::SUPER, Code::KeyQ), (UiAction::Find, Modifiers::SUPER, Code::KeyF), ( UiAction::SearchContents, Modifiers::SUPER | Modifiers::SHIFT, Code::KeyF, ), (UiAction::CommandPalette, Modifiers::SUPER, Code::KeyK), (UiAction::Refresh, Modifiers::SUPER, Code::KeyR), (UiAction::Lock, Modifiers::SUPER, Code::KeyL), (UiAction::Help, Modifiers::empty(), Code::F1), ] { let accelerator = accelerator(action).expect("registered native accelerator"); assert_eq!(accelerator.modifiers(), modifiers, "{action:?}"); assert_eq!(accelerator.key(), code, "{action:?}"); } assert!(accelerator(UiAction::GeneratePassword).is_none()); } }