From cd25e8e22918b8e2149f6f977c90bf49df24582e Mon Sep 17 00:00:00 2001 From: Vasil Zakiev Date: Thu, 7 May 2026 15:43:29 +0300 Subject: [PATCH] Add macOS native File submenu with HIG-conformant Quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a File submenu to the native macOS menubar that mirrors the in-window burger button (≡) for the actions that belong in File per Apple HIG: New File (⌘T), Open File… (⌘O), Close (⌘W), Save (⌘S), Save As… (⌘⇧S), Export as HTML, and Export as PDF. Home stays in the burger button only because it's a navigation toggle, not a file action. Quit moves to where macOS users actually look for it. The Markpad app submenu's PredefinedMenuItem::quit (no accelerator) is replaced with a custom item that owns ⌘Q and routes through the existing appExit() flow, preserving the unsaved-changes prompt and the appWindow.onCloseRequested hook that PredefinedMenuItem::quit otherwise bypasses. As a side effect, this fixes ⌘Q closing the window on macOS without an unsaved-changes prompt — the previous keydown handler called getCurrentWindow().close() directly, skipping appExit. Wiring follows the same emit/listen pattern PR #130 introduced for "Check for Updates…" — Rust's .on_menu_event pushes the menu id as an event and MarkdownViewer.svelte listens, calling the same handlers the burger button uses (handleNewFile, selectFile, closeFile, saveContent, saveContentAs, exportAsHtml, exportAsPdf, appExit). The four bare keydown branches whose accelerators are now owned by the menu (⌘T, ⌘W, ⌘S, ⌘Q) are skipped on macOS to avoid double-handling; ⌘⇧T (undo close tab) and ⌘⇧S keep their existing routes because the !e.shiftKey guard only excludes the bare combos. Windows and Linux are unchanged: the menu block stays inside the existing #[cfg(target_os = "macos")] from PR #130 so frameless windows on those platforms keep the burger as the only entry point and don't get a clashing in-window menu bar. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/Cargo.lock | 2 +- src-tauri/src/lib.rs | 69 +++++++++++++++++++++++++++++++++-- src/lib/MarkdownViewer.svelte | 27 ++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5423c23..9b9376c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Markpad" -version = "2.6.6" +version = "2.6.8" dependencies = [ "arboard", "base64 0.22.1", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e14385f..846f0e5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -972,7 +972,52 @@ pub fn run() { .item(&PredefinedMenuItem::hide_others(app, None)?) .item(&PredefinedMenuItem::show_all(app, None)?) .separator() - .item(&PredefinedMenuItem::quit(app, None)?) + .item( + &MenuItemBuilder::with_id( + "menu-app-quit", + format!("Quit {}", app_name), + ) + .accelerator("CmdOrCtrl+Q") + .build(app)?, + ) + .build()?; + + let file_submenu = SubmenuBuilder::new(app, "File") + .item( + &MenuItemBuilder::with_id("menu-file-new", "New File") + .accelerator("CmdOrCtrl+T") + .build(app)?, + ) + .item( + &MenuItemBuilder::with_id("menu-file-open", "Open File…") + .accelerator("CmdOrCtrl+O") + .build(app)?, + ) + .item( + &MenuItemBuilder::with_id("menu-file-close", "Close") + .accelerator("CmdOrCtrl+W") + .build(app)?, + ) + .separator() + .item( + &MenuItemBuilder::with_id("menu-file-save", "Save") + .accelerator("CmdOrCtrl+S") + .build(app)?, + ) + .item( + &MenuItemBuilder::with_id("menu-file-save-as", "Save As…") + .accelerator("CmdOrCtrl+Shift+S") + .build(app)?, + ) + .separator() + .item( + &MenuItemBuilder::with_id("menu-file-export-html", "Export as HTML") + .build(app)?, + ) + .item( + &MenuItemBuilder::with_id("menu-file-export-pdf", "Export as PDF") + .build(app)?, + ) .build()?; let edit_submenu = SubmenuBuilder::new(app, "Edit") @@ -991,7 +1036,7 @@ pub fn run() { .build()?; let menu = MenuBuilder::new(app) - .items(&[&app_submenu, &edit_submenu, &window_submenu]) + .items(&[&app_submenu, &file_submenu, &edit_submenu, &window_submenu]) .build()?; app.set_menu(menu)?; @@ -1078,8 +1123,24 @@ pub fn run() { list_directory_contents ]) .on_menu_event(|app, event| { - if event.id().as_ref() == "check-updates" { - let _ = app.emit("menu-check-updates", ()); + let id = event.id().as_ref(); + // Emit to the focused webview window rather than `app.emit(...)`, + // which would broadcast to every webview. Markpad is currently + // single-window, but additional webviews (e.g. detached tabs) + // would otherwise receive duplicate New/Close/Save invocations. + // Falls back to "main" if no window is focused (e.g. menu fired + // while the app is in the background). + let target = app + .webview_windows() + .into_values() + .find(|w| w.is_focused().unwrap_or(false)) + .or_else(|| app.get_webview_window("main")); + let Some(window) = target else { return }; + + if id == "check-updates" { + let _ = window.emit("menu-check-updates", ()); + } else if id == "menu-app-quit" || id.starts_with("menu-file-") { + let _ = window.emit(id, ()); } }) .build(tauri::generate_context!()) diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 2f1c84b..69d3fd1 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -2013,6 +2013,18 @@ import { t } from './utils/i18n.js'; const key = e.key.toLowerCase(); const code = e.code; + // On macOS the native menu accelerators (⌘T, ⌘W, ⌘S, ⌘Q) take priority + // via NSMenu; the JS keydown handler should not also fire for them, or + // we'd double-handle (e.g. open two new tabs on ⌘T). The !e.shiftKey + // guards keep ⌘⇧T (undo close tab) routed through this handler as + // before — only the bare combos are claimed by the menu. + if (settings.osType === 'macos' && cmdOrCtrl && !e.shiftKey) { + if (key === 'q') return; // → menu-app-quit + if (key === 'w') return; // → menu-file-close + if (key === 's') return; // → menu-file-save + if (key === 't') return; // → menu-file-new + } + const isSplit = tabManager.activeTab?.isSplit; if (cmdOrCtrl && key === 'w') { @@ -2360,6 +2372,21 @@ import { t } from './utils/i18n.js'; updateStore.openDialog(); }), ); + // Native macOS menubar — Markpad ▸ Quit and File ▸ * — bridged + // to the same handlers the in-window burger button uses, so the + // menu and the burger stay behaviourally identical. Save mirrors + // the keydown guard (`isEditing || isSplit`) so menu ⌘S in pure + // view mode is a no-op, matching the keyboard shortcut. + unlisteners.push(await listen('menu-app-quit', () => appExit())); + unlisteners.push(await listen('menu-file-new', () => handleNewFile())); + unlisteners.push(await listen('menu-file-open', () => selectFile())); + unlisteners.push(await listen('menu-file-close', () => closeFile())); + unlisteners.push(await listen('menu-file-save', () => { + if (isEditing || tabManager.activeTab?.isSplit) saveContent(); + })); + unlisteners.push(await listen('menu-file-save-as', () => saveContentAs())); + unlisteners.push(await listen('menu-file-export-html', () => exportAsHtml())); + unlisteners.push(await listen('menu-file-export-pdf', () => exportAsPdf())); unlisteners.push( await appWindow.onCloseRequested(async (event) => { console.log('onCloseRequested triggered');