use std::collections::HashMap; use iced::Subscription; use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers}; use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu}; use crate::app::Message; use bds_core::i18n::{UiLocale, translate}; /// Every custom menu item that the application handles. /// /// Predefined OS items (Undo, Redo, Cut, Copy, Paste, SelectAll, etc.) /// are handled by the platform and do not appear here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MenuAction { // File NewPost, ImportMedia, Save, OpenInBrowser, OpenDataFolder, // Edit (custom items only) Find, Replace, EditPreferences, // View ViewPosts, ViewMedia, ToggleSidebar, TogglePanel, // Window PublishSelected, PreviewPost, EditMenu, RebuildDatabase, ReindexText, MetadataDiff, RegenerateCalendar, ValidateTranslations, FillMissingTranslations, GenerateSitemap, ValidateSite, UploadSite, // Help About, OpenDocumentation, ViewOnGitHub, ReportIssue, } impl MenuAction { /// All variants in declaration order. pub const ALL: &'static [MenuAction] = &[ MenuAction::NewPost, MenuAction::ImportMedia, MenuAction::Save, MenuAction::OpenInBrowser, MenuAction::OpenDataFolder, MenuAction::Find, MenuAction::Replace, MenuAction::EditPreferences, MenuAction::ViewPosts, MenuAction::ViewMedia, MenuAction::ToggleSidebar, MenuAction::TogglePanel, MenuAction::PublishSelected, MenuAction::PreviewPost, MenuAction::EditMenu, MenuAction::RebuildDatabase, MenuAction::ReindexText, MenuAction::MetadataDiff, MenuAction::RegenerateCalendar, MenuAction::ValidateTranslations, MenuAction::FillMissingTranslations, MenuAction::GenerateSitemap, MenuAction::ValidateSite, MenuAction::UploadSite, MenuAction::About, MenuAction::OpenDocumentation, MenuAction::ViewOnGitHub, MenuAction::ReportIssue, ]; /// Return the i18n key for this action's menu label. pub fn i18n_key(self) -> &'static str { match self { Self::NewPost => "menu.item.newPost", Self::ImportMedia => "menu.item.importMedia", Self::Save => "menu.item.save", Self::OpenInBrowser => "menu.item.openInBrowser", Self::OpenDataFolder => "menu.item.openDataFolder", Self::Find => "menu.item.find", Self::Replace => "menu.item.replace", Self::EditPreferences => "menu.item.editPreferences", Self::ViewPosts => "menu.item.viewPosts", Self::ViewMedia => "menu.item.viewMedia", Self::ToggleSidebar => "menu.item.toggleSidebar", Self::TogglePanel => "menu.item.togglePanel", Self::PublishSelected => "menu.item.publishSelected", Self::PreviewPost => "menu.item.previewPost", Self::EditMenu => "menu.item.editMenu", Self::RebuildDatabase => "menu.item.rebuildDatabase", Self::ReindexText => "menu.item.reindexText", Self::MetadataDiff => "menu.item.metadataDiff", Self::RegenerateCalendar => "menu.item.regenerateCalendar", Self::ValidateTranslations => "menu.item.validateTranslations", Self::FillMissingTranslations => "menu.item.fillMissingTranslations", Self::GenerateSitemap => "menu.item.generateSitemap", Self::ValidateSite => "menu.item.validateSite", Self::UploadSite => "menu.item.uploadSite", Self::About => "menu.item.about", Self::OpenDocumentation => "menu.item.openDocumentation", Self::ViewOnGitHub => "menu.item.viewOnGitHub", Self::ReportIssue => "menu.item.reportIssue", } } } /// Maps between muda `MenuId`s and application `MenuAction`s. /// /// Also holds clones of the `MenuItem` handles so that labels and /// enabled state can be changed at runtime (e.g. on locale switch). pub struct MenuRegistry { action_map: HashMap, id_map: HashMap, items: HashMap, } impl MenuRegistry { fn new() -> Self { Self { action_map: HashMap::new(), id_map: HashMap::new(), items: HashMap::new(), } } #[cfg(test)] pub(crate) fn empty() -> Self { Self::new() } fn register(&mut self, action: MenuAction, item: &MenuItem) { self.action_map.insert(item.id().clone(), action); self.id_map.insert(action, item.id().clone()); self.items.insert(action, item.clone()); } /// Look up the `MenuAction` for a raw muda event id. pub fn lookup(&self, id: &MenuId) -> Option { self.action_map.get(id).copied() } /// Enable or disable the menu item for a given action. pub fn set_enabled(&self, action: MenuAction, enabled: bool) { if let Some(item) = self.items.get(&action) { item.set_enabled(enabled); } } /// Change the displayed text for a given action. pub fn set_text(&self, action: MenuAction, text: &str) { if let Some(item) = self.items.get(&action) { item.set_text(text); } } /// Number of registered (action, MenuId) pairs. pub fn action_count(&self) -> usize { self.action_map.len() } } // --------------------------------------------------------------------------- // Menu construction helpers // --------------------------------------------------------------------------- /// Helper: create a `MenuItem`, register it, and return a reference for /// appending to a `Submenu`. fn item( registry: &mut MenuRegistry, action: MenuAction, locale: UiLocale, accel: Option, ) -> MenuItem { let label = translate(locale, action.i18n_key()); let mi = MenuItem::new(label, true, accel); registry.register(action, &mi); mi } /// Build the full native menu bar and a registry that maps ids to actions. /// /// On macOS this also calls `init_for_nsapp()` to attach the menu. pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { let menu = Menu::new(); let mut reg = MenuRegistry::new(); // -- macOS app menu -- let app_menu = Submenu::new("bDS", true); let _ = app_menu.append(&PredefinedMenuItem::about(None, None)); let _ = app_menu.append(&PredefinedMenuItem::separator()); let _ = app_menu.append(&PredefinedMenuItem::services(None)); let _ = app_menu.append(&PredefinedMenuItem::separator()); let _ = app_menu.append(&PredefinedMenuItem::hide(None)); let _ = app_menu.append(&PredefinedMenuItem::hide_others(None)); let _ = app_menu.append(&PredefinedMenuItem::show_all(None)); let _ = app_menu.append(&PredefinedMenuItem::separator()); let _ = app_menu.append(&PredefinedMenuItem::quit(None)); // -- File -- let file_menu = Submenu::new(translate(locale, "menu.group.file"), true); let _ = file_menu.append(&item( &mut reg, MenuAction::NewPost, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)), )); let _ = file_menu.append(&item( &mut reg, MenuAction::ImportMedia, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI)), )); let _ = file_menu.append(&item( &mut reg, MenuAction::Save, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS)), )); let _ = file_menu.append(&PredefinedMenuItem::separator()); let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None)); let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None)); let _ = file_menu.append(&PredefinedMenuItem::separator()); let _ = file_menu.append(&PredefinedMenuItem::close_window(None)); // -- Edit -- let edit_menu = Submenu::new(translate(locale, "menu.group.edit"), true); let _ = edit_menu.append(&PredefinedMenuItem::undo(None)); let _ = edit_menu.append(&PredefinedMenuItem::redo(None)); let _ = edit_menu.append(&PredefinedMenuItem::separator()); let _ = edit_menu.append(&PredefinedMenuItem::cut(None)); let _ = edit_menu.append(&PredefinedMenuItem::copy(None)); let _ = edit_menu.append(&PredefinedMenuItem::paste(None)); let _ = edit_menu.append(&PredefinedMenuItem::select_all(None)); let _ = edit_menu.append(&PredefinedMenuItem::separator()); let _ = edit_menu.append(&item( &mut reg, MenuAction::Find, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF)), )); let _ = edit_menu.append(&item( &mut reg, MenuAction::Replace, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH)), )); let _ = edit_menu.append(&PredefinedMenuItem::separator()); let _ = edit_menu.append(&item( &mut reg, MenuAction::EditPreferences, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)), )); // -- View -- let view_menu = Submenu::new(translate(locale, "menu.group.view"), true); let _ = view_menu.append(&item( &mut reg, MenuAction::ViewPosts, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1)), )); let _ = view_menu.append(&item( &mut reg, MenuAction::ViewMedia, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2)), )); let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&item( &mut reg, MenuAction::ToggleSidebar, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB)), )); let _ = view_menu.append(&item( &mut reg, MenuAction::TogglePanel, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ)), )); let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None)); // -- Blog -- let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true); let _ = blog_menu.append(&item( &mut reg, MenuAction::PublishSelected, locale, Some(Accelerator::new( Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP, )), )); let _ = blog_menu.append(&item( &mut reg, MenuAction::PreviewPost, locale, Some(Accelerator::new( Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV, )), )); let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None)); let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&item( &mut reg, MenuAction::RegenerateCalendar, locale, None, )); let _ = blog_menu.append(&item( &mut reg, MenuAction::ValidateTranslations, locale, None, )); let _ = blog_menu.append(&item( &mut reg, MenuAction::FillMissingTranslations, locale, None, )); let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&item( &mut reg, MenuAction::GenerateSitemap, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)), )); let _ = blog_menu.append(&item( &mut reg, MenuAction::ValidateSite, locale, Some(Accelerator::new( Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL, )), )); let _ = blog_menu.append(&item( &mut reg, MenuAction::UploadSite, locale, Some(Accelerator::new( Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU, )), )); // -- Help -- let help_menu = Submenu::new(translate(locale, "menu.group.help"), true); let _ = help_menu.append(&item(&mut reg, MenuAction::About, locale, None)); let _ = help_menu.append(&PredefinedMenuItem::separator()); let _ = help_menu.append(&item(&mut reg, MenuAction::OpenDocumentation, locale, None)); let _ = help_menu.append(&item(&mut reg, MenuAction::ViewOnGitHub, locale, None)); let _ = help_menu.append(&item(&mut reg, MenuAction::ReportIssue, locale, None)); // Assemble the menu bar let _ = menu.append(&app_menu); let _ = menu.append(&file_menu); let _ = menu.append(&edit_menu); let _ = menu.append(&view_menu); let _ = menu.append(&blog_menu); let _ = menu.append(&help_menu); (menu, reg) } /// Attach the built menu to the macOS NSApp. /// /// Must be called **after** the event loop has started (e.g. from the /// init task or first update), not during `build_menu_bar`. #[cfg(target_os = "macos")] pub fn init_menu_for_nsapp(menu: &Menu) { menu.init_for_nsapp(); } /// Re-translate every registered menu item for a new locale. pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) { for &action in MenuAction::ALL { registry.set_text(action, &translate(locale, action.i18n_key())); } } /// Iced subscription that polls muda `MenuEvent`s each frame. /// /// Produces `Message::MenuEvent(MenuId)` so the app can look up the /// `MenuAction` via its `MenuRegistry`. pub fn menu_subscription() -> Subscription { iced::event::listen_with(|_event, _status, _id| { if let Ok(event) = MenuEvent::receiver().try_recv() { Some(Message::MenuEvent(event.id)) } else { None } }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use bds_core::i18n::UiLocale; #[test] fn all_variants_are_listed() { // MenuAction::ALL must contain every variant. // If someone adds a variant but forgets ALL, the i18n_key match // will produce a compile error, but this also catches ALL length. assert_eq!(MenuAction::ALL.len(), 28); } #[test] fn i18n_keys_resolve_for_english() { for &action in MenuAction::ALL { let key = action.i18n_key(); let label = translate(UiLocale::En, key); assert_ne!(label, key, "missing English translation for {key}"); } } #[test] fn registry_register_and_lookup_roundtrip() { let mut reg = MenuRegistry::new(); let mi = MenuItem::new("Test", true, None); reg.register(MenuAction::Save, &mi); assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save)); assert_eq!(reg.action_count(), 1); } #[test] fn registry_lookup_missing_returns_none() { let reg = MenuRegistry::new(); let bogus = MenuId::new("nonexistent"); assert_eq!(reg.lookup(&bogus), None); } #[test] fn registry_set_enabled_and_text() { let mut reg = MenuRegistry::new(); let mi = MenuItem::new("Original", true, None); reg.register(MenuAction::NewPost, &mi); reg.set_enabled(MenuAction::NewPost, false); assert!(!mi.is_enabled()); reg.set_text(MenuAction::NewPost, "Changed"); assert_eq!(mi.text(), "Changed"); } #[test] fn all_actions_have_unique_i18n_keys() { let mut seen = std::collections::HashSet::new(); for &action in MenuAction::ALL { let key = action.i18n_key(); assert!(seen.insert(key), "duplicate i18n key: {key}"); } } }