use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers}; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use crate::app::DetailTab; use crate::native_edit::EditCommand; const PREFERENCES: &str = "preferences"; const MODEL_MANAGER: &str = "model-manager"; const NEW_CHAT: &str = "new-chat"; const EXPORT_CHAT: &str = "export-chat"; const TOGGLE_SIDEBAR: &str = "toggle-sidebar"; const SHOW_CHAT: &str = "show-chat"; const SHOW_A2UI: &str = "show-a2ui"; const SHOW_GIT: &str = "show-git"; const SHOW_STATS: &str = "show-stats"; const HELP: &str = "help"; const QUIT: &str = "quit"; const UNDO: &str = "undo"; const REDO: &str = "redo"; const CUT: &str = "cut"; const COPY: &str = "copy"; const PASTE: &str = "paste"; const SELECT_ALL: &str = "select-all"; pub(crate) struct NativeMenu { _menu: Menu, new_chat: MenuItem, export_chat: MenuItem, sidebar: CheckMenuItem, chat: CheckMenuItem, a2ui: CheckMenuItem, git: CheckMenuItem, stats: CheckMenuItem, undo: MenuItem, redo: MenuItem, cut: MenuItem, copy: MenuItem, paste: MenuItem, select_all: MenuItem, } #[derive(Clone, Copy, Debug)] pub(crate) enum NativeMenuEvent { Preferences, ModelManager, NewChat, ExportChat, ToggleSidebar, ShowChat, ShowA2ui, ShowGit, ShowStats, Help, Quit, Edit(EditCommand), } pub(crate) fn install() -> Result { let menu = Menu::new(); let application = Submenu::new("DS4Server", true); let file = Submenu::new("File", true); let edit = Submenu::new("Edit", true); let view = Submenu::new("View", true); let window = Submenu::new("Window", true); let help = Submenu::new("Help", true); let preferences = MenuItem::with_id( PREFERENCES, "Preferences…", true, Some(Accelerator::new(Some(Modifiers::SUPER), Code::Comma)), ); let model_manager = MenuItem::with_id( MODEL_MANAGER, "Model Manager…", true, Some(Accelerator::new( Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM, )), ); let new_chat = MenuItem::with_id( NEW_CHAT, "New Chat", false, Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyN)), ); let export_chat = MenuItem::with_id( EXPORT_CHAT, "Export Chat as Markdown…", false, Some(Accelerator::new( Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyS, )), ); let sidebar = CheckMenuItem::with_id( TOGGLE_SIDEBAR, "Show Sidebar", true, true, Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyB)), ); let chat = view_item(SHOW_CHAT, "Chat", Code::Digit1, true); let a2ui = view_item(SHOW_A2UI, "A2UI", Code::Digit2, false); let git = view_item(SHOW_GIT, "Git", Code::Digit3, false); let stats = view_item(SHOW_STATS, "Stats", Code::Digit4, false); let open_help = MenuItem::with_id( HELP, "DS4Server Help", true, Some(Accelerator::new( Some(Modifiers::SUPER | Modifiers::SHIFT), Code::Slash, )), ); let undo = edit_item(UNDO, "Undo", Code::KeyZ, None); let redo = edit_item(REDO, "Redo", Code::KeyZ, Some(Modifiers::SHIFT)); let cut = edit_item(CUT, "Cut", Code::KeyX, None); let copy = edit_item(COPY, "Copy", Code::KeyC, None); let paste = edit_item(PASTE, "Paste", Code::KeyV, None); let select_all = edit_item(SELECT_ALL, "Select All", Code::KeyA, None); let quit = MenuItem::with_id( QUIT, "Quit DS4Server", true, Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyQ)), ); application .append_items(&[ &PredefinedMenuItem::about(None, None), &PredefinedMenuItem::separator(), &preferences, &PredefinedMenuItem::separator(), &PredefinedMenuItem::services(None), &PredefinedMenuItem::separator(), &PredefinedMenuItem::hide(None), &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), &quit, ]) .map_err(|error| error.to_string())?; file.append_items(&[ &new_chat, &export_chat, &PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None), ]) .map_err(|error| error.to_string())?; edit.append_items(&[ &undo, &redo, &PredefinedMenuItem::separator(), &cut, ©, &paste, &select_all, ]) .map_err(|error| error.to_string())?; view.append_items(&[ &sidebar, &PredefinedMenuItem::separator(), &chat, &a2ui, &git, &stats, &PredefinedMenuItem::separator(), &model_manager, ]) .map_err(|error| error.to_string())?; window .append_items(&[ &PredefinedMenuItem::minimize(None), &PredefinedMenuItem::maximize(None), &PredefinedMenuItem::close_window(None), &PredefinedMenuItem::bring_all_to_front(None), ]) .map_err(|error| error.to_string())?; help.append(&open_help).map_err(|error| error.to_string())?; menu.append_items(&[&application, &file, &edit, &view, &window, &help]) .map_err(|error| error.to_string())?; menu.init_for_nsapp(); window.set_as_windows_menu_for_nsapp(); Ok(NativeMenu { _menu: menu, new_chat, export_chat, sidebar, chat, a2ui, git, stats, undo, redo, cut, copy, paste, select_all, }) } impl NativeMenu { pub(crate) fn update( &self, project_active: bool, chat_active: bool, sidebar_visible: bool, detail_tab: DetailTab, edit: crate::native_edit::EditAvailability, ) { self.new_chat.set_enabled(project_active); self.export_chat.set_enabled(chat_active); self.sidebar.set_checked(sidebar_visible); self.chat.set_checked(detail_tab == DetailTab::Chat); self.a2ui.set_checked(detail_tab == DetailTab::A2ui); self.git.set_checked(detail_tab == DetailTab::Git); self.stats.set_checked(detail_tab == DetailTab::Stats); self.undo.set_enabled(edit.undo); self.redo.set_enabled(edit.redo); self.cut.set_enabled(edit.cut); self.copy.set_enabled(edit.copy); self.paste.set_enabled(edit.paste); self.select_all.set_enabled(edit.select_all); } } pub(crate) fn next_event() -> Option { while let Ok(event) = MenuEvent::receiver().try_recv() { let event = match event.id.0.as_str() { PREFERENCES => NativeMenuEvent::Preferences, MODEL_MANAGER => NativeMenuEvent::ModelManager, NEW_CHAT => NativeMenuEvent::NewChat, EXPORT_CHAT => NativeMenuEvent::ExportChat, TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar, SHOW_CHAT => NativeMenuEvent::ShowChat, SHOW_A2UI => NativeMenuEvent::ShowA2ui, SHOW_GIT => NativeMenuEvent::ShowGit, SHOW_STATS => NativeMenuEvent::ShowStats, HELP => NativeMenuEvent::Help, QUIT => NativeMenuEvent::Quit, UNDO => NativeMenuEvent::Edit(EditCommand::Undo), REDO => NativeMenuEvent::Edit(EditCommand::Redo), CUT => NativeMenuEvent::Edit(EditCommand::Cut), COPY => NativeMenuEvent::Edit(EditCommand::Copy), PASTE => NativeMenuEvent::Edit(EditCommand::Paste), SELECT_ALL => NativeMenuEvent::Edit(EditCommand::SelectAll), _ => continue, }; return Some(event); } None } fn view_item(id: &'static str, label: &'static str, code: Code, checked: bool) -> CheckMenuItem { CheckMenuItem::with_id( id, label, true, checked, Some(Accelerator::new(Some(Modifiers::SUPER), code)), ) } fn edit_item( id: &'static str, label: &'static str, code: Code, extra_modifier: Option, ) -> MenuItem { MenuItem::with_id( id, label, true, Some(Accelerator::new( Some(CMD_OR_CTRL | extra_modifier.unwrap_or_else(Modifiers::empty)), code, )), ) } #[cfg(test)] mod tests { use super::*; #[test] fn edit_items_are_custom_menu_events() { let paste = edit_item(PASTE, "Paste", Code::KeyV, None); assert_eq!(paste.id().0, PASTE); assert!(paste.is_enabled()); } #[test] fn view_items_keep_the_selected_pane_checked() { let chat = view_item(SHOW_CHAT, "Chat", Code::Digit1, true); let stats = view_item(SHOW_STATS, "Stats", Code::Digit4, false); assert_eq!(chat.id().0, SHOW_CHAT); assert!(chat.is_checked()); assert_eq!(stats.id().0, SHOW_STATS); assert!(!stats.is_checked()); } }