Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions scripts/issue281MinimalMacosMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,40 @@ import { readSource, sliceBetween } from './sourceTree.js';
const tauriLib = readSource('src-tauri/src/lib.rs');
const viewer = readSource('src/lib/MarkdownViewer.svelte');

test('macOS native menu keeps only application-level actions', () => {
const menuSetup = sliceBetween(
tauriLib,
'#[cfg(target_os = "macos")]\n {\n use tauri::menu',
'\n let config_dir',
);
const menuSetup = sliceBetween(
tauriLib,
'#[cfg(target_os = "macos")]\n {\n use tauri::menu',
'\n let config_dir',
);

test('macOS native menu keeps document actions out', () => {
assert.match(menuSetup, /MenuItemBuilder::with_id\("menu-app-settings", "Settings…"\)\s*\.accelerator\("CmdOrCtrl\+,"\)/);
assert.match(menuSetup, /MenuItemBuilder::with_id\("check-updates", "Check for Updates…"\)/);
assert.match(menuSetup, /PredefinedMenuItem::services\(app, None\)/);
assert.match(menuSetup, /PredefinedMenuItem::hide\(app, None\)/);
assert.doesNotMatch(menuSetup, /PredefinedMenuItem::(?:hide_others|show_all)/);
assert.match(menuSetup, /\.items\(&\[&app_submenu\]\)/);
assert.doesNotMatch(menuSetup, /SubmenuBuilder::new\(app, "(?:File|Edit|Window)"\)/);
assert.match(menuSetup, /\.items\(&\[&app_submenu, &edit_submenu, &window_submenu\]\)/);
assert.doesNotMatch(menuSetup, /SubmenuBuilder::new\(app, "File"\)/);
// New/Open/Save/Close, zoom, preview width and tab cycling are all bound in
// `MarkdownViewer.handleKeyDown`, which is web-side and needs no menu item.
assert.doesNotMatch(menuSetup, /PredefinedMenuItem::close_window/);
});

// Document actions stay in the in-window controls (#281). What the menu still
// owes macOS is the commands the web side cannot bind at all: text editing,
// which AppKit routes through the main menu to the first responder (#526), and
// the two window-server actions below.
test('macOS menu keeps the standard Edit items so text fields can paste', () => {
assert.match(menuSetup, /SubmenuBuilder::new\(app, "Edit"\)/);
for (const item of ['undo', 'redo', 'cut', 'copy', 'paste', 'select_all']) {
assert.match(menuSetup, new RegExp(`PredefinedMenuItem::${item}\\(app, None\\)`));
}
});

test('macOS menu carries ⌘M and ⌃⌘F, which have no web-side equivalent', () => {
assert.match(menuSetup, /SubmenuBuilder::new\(app, "Window"\)/);
assert.match(menuSetup, /PredefinedMenuItem::minimize\(app, None\)/);
assert.match(menuSetup, /PredefinedMenuItem::fullscreen\(app, None\)/);
});

test('native Settings opens only the focused window settings modal', () => {
Expand Down
14 changes: 12 additions & 2 deletions scripts/menuModalGuards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ test('titlebar menus close before a document context menu opens', () => {
assert.match(titleBar, /window\.addEventListener\('blur', handleGlobalDismiss\)/);
});

test('modal backdrop consumes context-menu events', () => {
assert.match(modal, /oncontextmenu=\{\(e\) => \{ e\.preventDefault\(\); e\.stopPropagation\(\); \}\}/);
test('modal backdrop consumes context-menu events outside text fields', () => {
assert.match(
modal,
/if \(\(e\.target as HTMLElement\)\.closest\('input, textarea'\)\) return;\n\t\t\te\.preventDefault\(\);\n\t\t\te\.stopPropagation\(\);/,
);
});

test('text fields keep the webview edit menu so paste stays reachable', () => {
assert.match(
viewer,
/if \(\(e\.target as HTMLElement\)\.closest\('input, textarea, \[contenteditable="true"\]'\)\) return;\n\t\te\.preventDefault\(\);/,
);
});
29 changes: 28 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2821,8 +2821,35 @@ pub fn run() {
)
.build()?;

// WKWebView declines ⌘X/⌘C/⌘V/⌘A/⌘Z in plain inputs and hands
// them to the main menu, so without an Edit submenu those keys
// are dead in every native field (settings, modals). Monaco is
// unaffected either way: it preventDefault()s the key first, so
// the menu never sees it and its own paste handler still runs.
let edit_submenu = SubmenuBuilder::new(app, "Edit")
.item(&PredefinedMenuItem::undo(app, None)?)
.item(&PredefinedMenuItem::redo(app, None)?)
.separator()
.item(&PredefinedMenuItem::cut(app, None)?)
.item(&PredefinedMenuItem::copy(app, None)?)
.item(&PredefinedMenuItem::paste(app, None)?)
.item(&PredefinedMenuItem::select_all(app, None)?)
.build()?;

// ⌘M and ⌃⌘F are window-server actions, not document actions:
// there is no web API the in-window controls could bind them
// to, so they only exist as long as a menu item carries them.
// Full Screen belongs in a View menu by convention, but View
// would hold that one item and nothing else — Markpad's view
// actions are all in-window (#281) — so it rides here instead.
let window_submenu = SubmenuBuilder::new(app, "Window")
.item(&PredefinedMenuItem::minimize(app, None)?)
.separator()
.item(&PredefinedMenuItem::fullscreen(app, None)?)
.build()?;

let menu = MenuBuilder::new(app)
.items(&[&app_submenu])
.items(&[&app_submenu, &edit_submenu, &window_submenu])
.build()?;

app.set_menu(menu)?;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2117,6 +2117,10 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
if (mode !== 'app') return;
const isInsideEditor = (e.target as HTMLElement).closest('.editor-container');
if (isInsideEditor) return;
// Text fields keep the webview's own editing menu (Cut/Copy/Paste);
// the document menu below is about the rendered preview and has no
// edit items, so swallowing the native one leaves no way to paste.
if ((e.target as HTMLElement).closest('input, textarea, [contenteditable="true"]')) return;
e.preventDefault();

const selection = window.getSelection();
Expand Down
7 changes: 6 additions & 1 deletion src/lib/components/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@
class="modal-backdrop"
transition:fade={{ duration: 150 }}
onclick={handleBackdropClick}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
oncontextmenu={(e) => {
// The prompt input needs the webview's Cut/Copy/Paste menu.
if ((e.target as HTMLElement).closest('input, textarea')) return;
e.preventDefault();
e.stopPropagation();
}}
role="presentation">
<div
class="modal-content {kind}"
Expand Down