openpencil/crates/op-host-desktop/src/menu.rs
Kayshen-X b7a55b6831 feat(desktop): File ▸ Open Recent submenu in the native macOS menu bar
Add an Open Recent submenu to the native File menu, populated from
editor_ui.recent_files (file names, newest first; each item id
`recent:<index>`). MenuAction gains OpenRecent(usize), dispatched through
the existing FileAction::OpenRecent path. The muda submenu is rebuilt
(set_recent_files) whenever the recent list can change — at startup and
after any menu action / Finder open — with a disabled "No Recent
Documents" placeholder when empty. No-op off macOS.
2026-07-05 18:37:39 +08:00

368 lines
14 KiB
Rust

//! Native application menu bar (macOS only).
//!
//! On macOS the native menu bar is built with `muda` and attached to
//! the running NSApp. Menu selections arrive on `muda`'s global event
//! channel; [`poll`] drains them into a [`MenuAction`] the runner maps
//! onto the same `WidgetHostNative` calls the keyboard shortcuts use.
//!
//! Windows and Linux have no native menu — the in-canvas File menu is
//! the primary menu surface. On Windows the window is borderless (custom
//! chrome), and a native `muda` menu drawn inside the client area would
//! flash during `drag_resize_window()` when the OS repaints the window.
//! `muda` is gated to macOS in `Cargo.toml`; other platforms get stubs.
/// A menu selection, decoupled from `muda` so the runner matches on
/// a plain enum. Each variant maps onto an existing host action.
///
/// `muda` only runs on macOS / Windows; the Linux `backend` is a
/// stub whose `poll()` always returns `None`, so on Linux every
/// variant is unconstructed by design. Silence `-D dead_code`
/// there without weakening the lint on the platforms that actually
/// build the menu.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuAction {
New,
Open,
/// Open the recent-file at this index into `editor_ui.recent_files`
/// (the File ▸ Open Recent submenu is rebuilt in sync with that list,
/// so the index is valid at click time).
OpenRecent(usize),
Save,
SaveAs,
Export,
Undo,
Redo,
Cut,
Copy,
Paste,
SelectAll,
Duplicate,
Group,
Ungroup,
ToggleFullscreen,
ToggleGitPanel,
ToggleDesignMdPanel,
ToggleComponentBrowserPanel,
Quit,
CheckUpdates,
OpenGithub,
}
// --------------------------------------------------------------------
// macOS — the real `muda` backend.
// --------------------------------------------------------------------
#[cfg(target_os = "macos")]
mod backend {
use super::MenuAction;
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
// Stable menu-item ids — the wire between the `muda` menu and
// `action_for_id`. Kept as `&str` consts so the build + the
// dispatch can't drift.
const ID_NEW: &str = "new";
const ID_OPEN: &str = "open";
const ID_SAVE: &str = "save";
const ID_SAVE_AS: &str = "save-as";
const ID_EXPORT: &str = "export";
const ID_UNDO: &str = "undo";
const ID_REDO: &str = "redo";
const ID_CUT: &str = "cut";
const ID_COPY: &str = "copy";
const ID_PASTE: &str = "paste";
const ID_SELECT_ALL: &str = "select-all";
const ID_DUPLICATE: &str = "duplicate";
const ID_GROUP: &str = "group";
const ID_UNGROUP: &str = "ungroup";
const ID_FULLSCREEN: &str = "fullscreen";
const ID_GIT_PANEL: &str = "git-panel";
const ID_DESIGN_MD: &str = "design-md";
const ID_COMPONENT_BROWSER: &str = "component-browser";
const ID_QUIT: &str = "quit";
const ID_CHECK_UPDATES: &str = "check-updates";
const ID_GITHUB: &str = "github";
/// Prefix for File ▸ Open Recent items — the suffix is the index into
/// `editor_ui.recent_files` (e.g. `recent:0`). The disabled empty-state
/// item uses [`ID_RECENT_NONE`] and maps to no action.
const ID_OPEN_RECENT_PREFIX: &str = "recent:";
const ID_RECENT_NONE: &str = "recent-none";
/// Map a `muda` menu-item id string onto a [`MenuAction`].
fn action_for_id(id: &str) -> Option<MenuAction> {
if let Some(index) = id.strip_prefix(ID_OPEN_RECENT_PREFIX) {
return index.parse::<usize>().ok().map(MenuAction::OpenRecent);
}
Some(match id {
ID_NEW => MenuAction::New,
ID_OPEN => MenuAction::Open,
ID_SAVE => MenuAction::Save,
ID_SAVE_AS => MenuAction::SaveAs,
ID_EXPORT => MenuAction::Export,
ID_UNDO => MenuAction::Undo,
ID_REDO => MenuAction::Redo,
ID_CUT => MenuAction::Cut,
ID_COPY => MenuAction::Copy,
ID_PASTE => MenuAction::Paste,
ID_SELECT_ALL => MenuAction::SelectAll,
ID_DUPLICATE => MenuAction::Duplicate,
ID_GROUP => MenuAction::Group,
ID_UNGROUP => MenuAction::Ungroup,
ID_FULLSCREEN => MenuAction::ToggleFullscreen,
ID_GIT_PANEL => MenuAction::ToggleGitPanel,
ID_DESIGN_MD => MenuAction::ToggleDesignMdPanel,
ID_COMPONENT_BROWSER => MenuAction::ToggleComponentBrowserPanel,
ID_QUIT => MenuAction::Quit,
ID_CHECK_UPDATES => MenuAction::CheckUpdates,
ID_GITHUB => MenuAction::OpenGithub,
_ => return None,
})
}
/// Owns the `muda` `Menu`. Kept alive for the process lifetime —
/// dropping it would tear the native menu down. `recent_submenu` is
/// held so File ▸ Open Recent can be rebuilt as the recent-file list
/// changes ([`AppMenu::set_recent_files`]).
pub struct AppMenu {
_menu: Menu,
recent_submenu: Submenu,
}
fn primary() -> Modifiers {
Modifiers::META
}
fn accel(code: Code) -> Accelerator {
Accelerator::new(Some(primary()), code)
}
fn accel_shift(code: Code) -> Accelerator {
Accelerator::new(Some(primary() | Modifiers::SHIFT), code)
}
/// A custom, id-tagged menu item with an accelerator.
fn item(id: &str, text: &str, accel: Option<Accelerator>) -> MenuItem {
MenuItem::with_id(id, text, true, accel)
}
impl AppMenu {
/// Build the menu and attach it to the running app / window.
/// Must be called from `resumed`, after window creation — macOS
/// needs the NSApp to exist.
pub fn install(window: &winit::window::Window) -> Self {
let menu = Menu::new();
// macOS app menu — About / Services / Hide / Quit, the
// conventional first submenu macOS labels with the app
// name. Quit is custom-id'd so the runner drives the same
// clean-shutdown path as the window-close button.
{
let app_menu = Submenu::new("OpenPencil", true);
let _ = app_menu.append_items(&[
&PredefinedMenuItem::about(None, Some(about_metadata())),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&item(ID_QUIT, "Quit OpenPencil", Some(accel(Code::KeyQ))),
]);
let _ = menu.append(&app_menu);
}
// File menu. The Open Recent submenu is populated later via
// `set_recent_files` (empty at build time → a disabled
// placeholder until the runner seeds it from the recent list).
let recent_submenu = Submenu::new("Open Recent", true);
let file = Submenu::new("File", true);
let _ = file.append_items(&[
&item(ID_NEW, "New", Some(accel(Code::KeyN))),
&item(ID_OPEN, "Open\u{2026}", Some(accel(Code::KeyO))),
&recent_submenu,
&PredefinedMenuItem::separator(),
&item(ID_SAVE, "Save", Some(accel(Code::KeyS))),
&item(ID_SAVE_AS, "Save As\u{2026}", Some(accel_shift(Code::KeyS))),
&PredefinedMenuItem::separator(),
&item(
ID_EXPORT,
"Export Image\u{2026}",
Some(accel_shift(Code::KeyP)),
),
]);
let _ = menu.append(&file);
// Edit menu — custom items routed to the host's own
// selection / clipboard ops (the canvas is not a native
// text field, so `PredefinedMenuItem` copy/paste would be
// inert here).
let edit = Submenu::new("Edit", true);
let _ = edit.append_items(&[
&item(ID_UNDO, "Undo", Some(accel(Code::KeyZ))),
&item(ID_REDO, "Redo", Some(accel_shift(Code::KeyZ))),
&PredefinedMenuItem::separator(),
&item(ID_CUT, "Cut", Some(accel(Code::KeyX))),
&item(ID_COPY, "Copy", Some(accel(Code::KeyC))),
&item(ID_PASTE, "Paste", Some(accel(Code::KeyV))),
&item(ID_SELECT_ALL, "Select All", Some(accel(Code::KeyA))),
&PredefinedMenuItem::separator(),
&item(ID_DUPLICATE, "Duplicate", Some(accel(Code::KeyD))),
&item(ID_GROUP, "Group", Some(accel(Code::KeyG))),
&item(ID_UNGROUP, "Ungroup", Some(accel_shift(Code::KeyG))),
]);
let _ = menu.append(&edit);
// View menu.
let view = Submenu::new("View", true);
let fullscreen_accel =
Accelerator::new(Some(Modifiers::META | Modifiers::CONTROL), Code::KeyF);
let _ = view.append(&item(
ID_FULLSCREEN,
"Toggle Full Screen",
Some(fullscreen_accel),
));
let _ = view.append(&PredefinedMenuItem::separator());
let _ = view.append(&item(ID_GIT_PANEL, "Git Panel", None));
let _ = view.append(&item(ID_DESIGN_MD, "Design.md Panel", None));
let _ = view.append(&item(ID_COMPONENT_BROWSER, "UIKit Browser", None));
let _ = menu.append(&view);
// Help menu.
let help = Submenu::new("Help", true);
let _ = help.append_items(&[
&item(ID_CHECK_UPDATES, "Check for Updates\u{2026}", None),
&PredefinedMenuItem::separator(),
&item(ID_GITHUB, "OpenPencil on GitHub", None),
]);
let _ = menu.append(&help);
// Attach to the platform.
let _ = window; // not needed — macOS attaches to the NSApp
menu.init_for_nsapp();
let this = Self {
_menu: menu,
recent_submenu,
};
this.set_recent_files(&[]); // seed the disabled placeholder
this
}
/// Rebuild the File ▸ Open Recent submenu from `labels` (newest
/// first). Item `i` gets id `recent:i`, matching the runner's
/// `FileAction::OpenRecent(i)`; an empty list shows one disabled
/// "No Recent Documents" row. Call whenever `recent_files` changes.
pub fn set_recent_files(&self, labels: &[String]) {
while self.recent_submenu.remove_at(0).is_some() {}
if labels.is_empty() {
let none = MenuItem::with_id(ID_RECENT_NONE, "No Recent Documents", false, None);
let _ = self.recent_submenu.append(&none);
return;
}
for (i, label) in labels.iter().enumerate() {
let entry =
MenuItem::with_id(format!("{ID_OPEN_RECENT_PREFIX}{i}"), label, true, None);
let _ = self.recent_submenu.append(&entry);
}
}
}
fn about_metadata() -> muda::AboutMetadata {
muda::AboutMetadata {
name: Some("OpenPencil".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
..Default::default()
}
}
/// Drain one pending menu selection, if any.
pub fn poll() -> Option<MenuAction> {
while let Ok(event) = MenuEvent::receiver().try_recv() {
if let Some(action) = action_for_id(event.id.as_ref()) {
return Some(action);
}
// An unrecognized id (e.g. a predefined item handled
// natively) — skip it and keep draining.
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_known_id_maps_to_an_action() {
// The build appends exactly these ids; each must resolve.
for id in [
ID_NEW,
ID_OPEN,
ID_SAVE,
ID_SAVE_AS,
ID_EXPORT,
ID_UNDO,
ID_REDO,
ID_CUT,
ID_COPY,
ID_PASTE,
ID_SELECT_ALL,
ID_DUPLICATE,
ID_GROUP,
ID_UNGROUP,
ID_FULLSCREEN,
ID_GIT_PANEL,
ID_DESIGN_MD,
ID_COMPONENT_BROWSER,
ID_QUIT,
ID_CHECK_UPDATES,
ID_GITHUB,
] {
assert!(action_for_id(id).is_some(), "id {id} should map");
}
}
#[test]
fn an_unknown_id_maps_to_nothing() {
assert!(action_for_id("predefined-separator").is_none());
assert!(action_for_id("").is_none());
}
#[test]
fn recent_ids_map_to_open_recent() {
assert_eq!(action_for_id("recent:0"), Some(MenuAction::OpenRecent(0)));
assert_eq!(action_for_id("recent:9"), Some(MenuAction::OpenRecent(9)));
// The disabled placeholder + malformed suffixes map to nothing.
assert!(action_for_id(ID_RECENT_NONE).is_none());
assert!(action_for_id("recent:").is_none());
assert!(action_for_id("recent:x").is_none());
}
}
}
// --------------------------------------------------------------------
// Other targets (Windows / Linux) — no native menu; the in-canvas
// File menu is the menu surface there.
// --------------------------------------------------------------------
#[cfg(not(target_os = "macos"))]
mod backend {
use super::MenuAction;
pub struct AppMenu;
impl AppMenu {
pub fn install(_window: &winit::window::Window) -> Self {
Self
}
/// No native menu off macOS — the in-canvas File menu shows recents.
pub fn set_recent_files(&self, _labels: &[String]) {}
}
pub fn poll() -> Option<MenuAction> {
None
}
}
pub use backend::{poll, AppMenu};