diff --git a/src/main/window/mac-app-paste-input.test.ts b/src/main/window/mac-app-paste-input.test.ts new file mode 100644 index 00000000000..556bdee47be --- /dev/null +++ b/src/main/window/mac-app-paste-input.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isMacAppPasteInput } from './mac-app-paste-input' + +function cmdKey(key: string, code: string, extra: Partial = {}): Electron.Input { + return { + type: 'keyDown', + key, + code, + isAutoRepeat: false, + isComposing: false, + meta: true, + control: false, + alt: false, + shift: false, + location: 0, + modifiers: ['meta'], + ...extra + } +} + +describe('isMacAppPasteInput', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + ['US Cmd+V', cmdKey('v', 'KeyV'), true], + ['Caps Lock Cmd+V', cmdKey('V', 'KeyV'), true], + ['Turkish F Cmd+V (V lives on KeyC)', cmdKey('v', 'KeyC'), true], + ['Turkish F Cmd+C (C lives on KeyV)', cmdKey('c', 'KeyV'), false], + ['Dvorak Cmd+V (V lives on Period)', cmdKey('v', 'Period'), true], + ['Dvorak Cmd+K (K lives on KeyV)', cmdKey('k', 'KeyV'), false], + ['BÉPO Cmd+. (. lives on KeyV)', cmdKey('.', 'KeyV'), false], + ['Cyrillic Cmd+V (non-Latin key on KeyV)', cmdKey('м', 'KeyV'), true], + ['Korean Cmd+V (Hangul jamo on KeyV)', cmdKey('ㅍ', 'KeyV'), true], + ['Cmd+V during IME composition', cmdKey('Process', 'KeyV'), true], + ['Cmd+Shift+V', cmdKey('V', 'KeyV', { shift: true }), false] + ])('%s', (_name, input, expected) => { + expect(isMacAppPasteInput(input)).toBe(expected) + }) + + it('never claims paste off macOS', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + expect(isMacAppPasteInput(cmdKey('v', 'KeyV'))).toBe(false) + }) +}) diff --git a/src/main/window/mac-app-paste-input.ts b/src/main/window/mac-app-paste-input.ts new file mode 100644 index 00000000000..ac4b314e498 --- /dev/null +++ b/src/main/window/mac-app-paste-input.ts @@ -0,0 +1,14 @@ +import { logicalKeyTokenFromInput, physicalCodeKeyTokenFromInput } from '../../shared/keybindings' + +export function isMacAppPasteInput(input: Electron.Input): boolean { + return ( + process.platform === 'darwin' && + input.type === 'keyDown' && + input.meta && + !input.control && + !input.alt && + !input.shift && + // Why: the produced key wins so remapped layouts (Turkish F puts C on KeyV) don't paste; non-Latin and IME keys fall back to the physical key. + (logicalKeyTokenFromInput(input) ?? physicalCodeKeyTokenFromInput(input)) === 'V' + ) +} diff --git a/src/main/window/main-window-shortcut-routing.ts b/src/main/window/main-window-shortcut-routing.ts index 71e1cb44451..ba2ecb35424 100644 --- a/src/main/window/main-window-shortcut-routing.ts +++ b/src/main/window/main-window-shortcut-routing.ts @@ -20,7 +20,7 @@ import type { Store } from '../persistence' import type { CreateMainWindowOptions } from './main-window-contracts' import type { MainWindowFocusLifecycle } from './main-window-focus-lifecycle' import { sendResolvedWindowShortcutAction } from './main-window-shortcut-actions' -import { isMacAppPasteInput } from './main-window-visual-lifecycle' +import { isMacAppPasteInput } from './mac-app-paste-input' export function installMainWindowShortcutRouting(args: { focus: MainWindowFocusLifecycle diff --git a/src/main/window/main-window-visual-lifecycle.ts b/src/main/window/main-window-visual-lifecycle.ts index f26b823d60d..200d08c77b1 100644 --- a/src/main/window/main-window-visual-lifecycle.ts +++ b/src/main/window/main-window-visual-lifecycle.ts @@ -95,18 +95,6 @@ export function installMacosVisibilityRepaint(window: BrowserWindow): void { }) } -export function isMacAppPasteInput(input: Electron.Input): boolean { - return ( - process.platform === 'darwin' && - input.type === 'keyDown' && - input.meta && - !input.control && - !input.alt && - !input.shift && - (input.code === 'KeyV' || input.key.toLowerCase() === 'v') - ) -} - // Why: titlebar content center sits ~18 CSS px from top (×zoom); traffic lights are ~12px tall, so top edge = center − 6. export const TITLEBAR_CSS_CENTER = 18 export const TRAFFIC_LIGHT_RADIUS = 6