diff --git a/changes/mouse-keybindings.md b/changes/mouse-keybindings.md new file mode 100644 index 00000000..8bfd701b --- /dev/null +++ b/changes/mouse-keybindings.md @@ -0,0 +1,4 @@ +type: fixed +area: config + +- Fixed settings keybinding capture and runtime handling for mouse buttons, including side-button bindings like `MBTN_BACK` and `MBTN_FORWARD`. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 118ebb61..330ef3fb 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -571,13 +571,15 @@ See `config.example.jsonc` for detailed configuration options and more examples. { "key": "ArrowRight", "command": ["seek", 5] }, { "key": "ArrowLeft", "command": ["seek", -5] }, { "key": "Shift+ArrowRight", "command": ["seek", 30] }, + { "key": "MBTN_BACK", "command": ["sub-seek", -1] }, + { "key": "MBTN_FORWARD", "command": ["sub-seek", 1] }, { "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] }, { "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] } ] } ``` -**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). +**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). Mouse buttons use mpv button names: `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. **Disable a default binding:** Set command to `null`: diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 0acc6883..cf2f70b4 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -152,9 +152,13 @@ The `keybindings` array overrides or extends the overlay's built-in key handling "keybindings": [ { "key": "f", "command": ["cycle", "fullscreen"] }, { "key": "m", "command": ["cycle", "mute"] }, + { "key": "MBTN_BACK", "command": ["sub-seek", -1] }, + { "key": "MBTN_FORWARD", "command": ["sub-seek", 1] }, { "key": "Space", "command": null }, // disable default Space → pause ], } ``` +Mouse keybinding names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. + Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner. diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 39fead3b..59fe9dc8 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -24,6 +24,11 @@ local KEY_NAME_MAP = { BracketLeft = "[", BracketRight = "]", Backquote = "`", + MBTN_LEFT = "MBTN_LEFT", + MBTN_MID = "MBTN_MID", + MBTN_RIGHT = "MBTN_RIGHT", + MBTN_BACK = "MBTN_BACK", + MBTN_FORWARD = "MBTN_FORWARD", } local MODIFIER_MAP = { diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 7d31f79c..6272ce95 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -229,6 +229,14 @@ local ctx = { actionType = "mpv-command", command = { "quit" }, }, + { + key = { + code = "MBTN_BACK", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "sub-seek", -1 }, + }, { key = { code = "KeyW", @@ -317,6 +325,7 @@ local expected_mpv_bindings = { { keys = "L", command = { "sub-seek", 1 } }, { keys = "q", command = { "quit" } }, { keys = "Ctrl+w", command = { "quit" } }, + { keys = "MBTN_BACK", command = { "sub-seek", -1 } }, } for _, expected in ipairs(expected_mpv_bindings) do diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 41ad389e..64199b97 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -162,6 +162,46 @@ test('compileSessionBindings resolves CommandOrControl in DOM key strings per pl ); }); +test('compileSessionBindings supports mpv mouse button keybindings', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: [ + createKeybinding('MBTN_BACK', ['sub-seek', -1]), + createKeybinding('Shift+MBTN_FORWARD', ['sub-seek', 1]), + ], + platform: 'win32', + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual( + result.bindings.map((binding) => ({ + code: binding.key.code, + modifiers: binding.key.modifiers, + command: binding.actionType === 'mpv-command' ? binding.command : null, + })), + [ + { code: 'MBTN_BACK', modifiers: [], command: ['sub-seek', -1] }, + { code: 'MBTN_FORWARD', modifiers: ['shift'], command: ['sub-seek', 1] }, + ], + ); +}); + +test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ + openJimaku: 'MBTN_BACK', + }), + keybindings: [createKeybinding('MBTN_BACK', ['sub-seek', -1])], + platform: 'win32', + }); + + assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']); + assert.deepEqual( + result.warnings.map((warning) => `${warning.kind}:${warning.path}`), + ['unsupported:shortcuts.openJimaku'], + ); +}); + test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => { const result = compileSessionBindings({ shortcuts: createShortcuts({ diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index 0733afe2..23301877 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -30,6 +30,13 @@ type DraftBinding = { }; const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta']; +const MPV_MOUSE_BUTTON_CODES = new Set([ + 'MBTN_LEFT', + 'MBTN_MID', + 'MBTN_RIGHT', + 'MBTN_BACK', + 'MBTN_FORWARD', +]); const SESSION_SHORTCUT_ACTIONS: Array<{ key: keyof Omit; @@ -64,9 +71,18 @@ function isValidCommandEntry(value: unknown): value is string | number { return typeof value === 'string' || typeof value === 'number'; } -function normalizeCodeToken(token: string): string | null { +function normalizeCodeToken( + token: string, + options: { allowMouseButtons?: boolean } = {}, +): string | null { const normalized = token.trim(); if (!normalized) return null; + if (options.allowMouseButtons === true) { + const normalizedMouse = normalized.toUpperCase(); + if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) { + return normalizedMouse; + } + } if (/^[a-z]$/i.test(normalized)) { return `Key${normalized.toUpperCase()}`; } @@ -238,7 +254,7 @@ function parseDomKeyString( }; } - const code = normalizeCodeToken(keyToken); + const code = normalizeCodeToken(keyToken, { allowMouseButtons: true }); if (!code) { return { key: null, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 9fbfa2c0..507796ca 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -322,18 +322,31 @@ function installKeyboardTestGlobals() { } } - function dispatchDocumentMouseDown(event: { button: number; target?: unknown }): void { + function dispatchMousedown(event: { + button: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + target?: unknown; + }): void { const listeners = documentListeners.get('mousedown') ?? []; const mouseEvent = { button: event.button, - target: event.target ?? null, + ctrlKey: event.ctrlKey ?? false, + metaKey: event.metaKey ?? false, + altKey: event.altKey ?? false, + shiftKey: event.shiftKey ?? false, preventDefault: () => {}, + target: event.target ?? null, }; for (const listener of listeners) { listener(mouseEvent); } } + const dispatchDocumentMouseDown = dispatchMousedown; + function dispatchFocusInOnPopup(): void { const listeners = documentListeners.get('focusin') ?? []; const focusEvent = { @@ -389,6 +402,7 @@ function installKeyboardTestGlobals() { windowFocusCalls: () => windowFocusCalls, dispatchKeydown, dispatchDocumentMouseDown, + dispatchMousedown, dispatchFocusInOnPopup, dispatchWindowEvent, setPopupVisible: (value: boolean) => { @@ -1036,6 +1050,31 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward } }); +test('configured mouse button keybinding dispatches through overlay mouse handling', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'keybindings[0].key', + originalKey: 'MBTN_BACK', + key: { code: 'MBTN_BACK', modifiers: [] }, + actionType: 'mpv-command', + command: ['sub-seek', -1], + }, + ] as never); + testGlobals.setPlaybackPausedResponse(false); + + testGlobals.dispatchMousedown({ button: 3 }); + await wait(0); + + assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['sub-seek', -1]]); + } finally { + testGlobals.restore(); + } +}); + test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index fc04f013..fba6269a 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -37,6 +37,13 @@ export function createKeyboardHandlers( const CHORD_TIMEOUT_MS = 1000; const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; + const MOUSE_BUTTON_CODE_BY_BUTTON: Record = { + 0: 'MBTN_LEFT', + 1: 'MBTN_MID', + 2: 'MBTN_RIGHT', + 3: 'MBTN_BACK', + 4: 'MBTN_FORWARD', + }; let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingLookupRefreshAfterSubtitleSeek = false; let resetSelectionToStartOnNextSubtitleSync = false; @@ -81,6 +88,19 @@ export function createKeyboardHandlers( return parts.join('+'); } + function mouseEventToString(e: MouseEvent): string | null { + const code = MOUSE_BUTTON_CODE_BY_BUTTON[e.button]; + if (!code) return null; + + const parts: string[] = []; + if (e.ctrlKey) parts.push('Ctrl'); + if (e.altKey) parts.push('Alt'); + if (e.shiftKey) parts.push('Shift'); + if (e.metaKey) parts.push('Meta'); + parts.push(code); + return parts.join('+'); + } + function updateConfiguredShortcuts( shortcuts: Required, statsToggleKey?: string, @@ -1216,6 +1236,14 @@ export function createKeyboardHandlers( }); document.addEventListener('mousedown', (e: MouseEvent) => { + const mouseString = mouseEventToString(e); + const binding = mouseString ? ctx.state.sessionBindingMap.get(mouseString) : undefined; + if (binding) { + e.preventDefault(); + dispatchSessionBinding(binding); + return; + } + if (e.button === 2 && !isInteractiveTarget(e.target)) { e.preventDefault(); void window.electronAPI diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index cca69cc8..38abede0 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -57,6 +57,11 @@ const KEY_NAME_MAP: Record = { Super: 'Meta', Meta: 'Meta', Backspace: 'Backspace', + MBTN_LEFT: 'Mouse Left', + MBTN_MID: 'Mouse Middle', + MBTN_RIGHT: 'Mouse Right', + MBTN_BACK: 'Mouse Back', + MBTN_FORWARD: 'Mouse Forward', }; function normalizeKeyToken(token: string): string { diff --git a/src/settings/key-input.test.ts b/src/settings/key-input.test.ts index cb505da0..90306e7a 100644 --- a/src/settings/key-input.test.ts +++ b/src/settings/key-input.test.ts @@ -5,6 +5,7 @@ import { buildMpvKeybindingConfigValue, createMpvKeybindingRows, keyboardEventToConfigKey, + mouseEventToConfigKey, } from './key-input'; test('keyboardEventToConfigKey formats Electron accelerators from learned input', () => { @@ -75,6 +76,23 @@ test('keyboardEventToConfigKey formats mpv key bindings from learned input', () ); }); +test('mouseEventToConfigKey formats mpv mouse buttons from learned input', () => { + assert.equal( + mouseEventToConfigKey( + { button: 3, ctrlKey: false, altKey: false, shiftKey: false, metaKey: false }, + 'dom-code', + ), + 'MBTN_BACK', + ); + assert.equal( + mouseEventToConfigKey( + { button: 4, ctrlKey: false, altKey: true, shiftKey: true, metaKey: false }, + 'dom-code', + ), + 'Alt+Shift+MBTN_FORWARD', + ); +}); + test('MPV keybinding rows save default key moves as a disable plus replacement', () => { const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }]; const rows = createMpvKeybindingRows(defaults, []); diff --git a/src/settings/key-input.ts b/src/settings/key-input.ts index a3556e8f..354a5a97 100644 --- a/src/settings/key-input.ts +++ b/src/settings/key-input.ts @@ -11,6 +11,14 @@ export interface KeyboardInputLike { metaKey: boolean; } +export interface MouseInputLike { + button: number; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + metaKey: boolean; +} + export interface MpvKeybindingRow { defaultKey: string; key: string; @@ -79,6 +87,14 @@ const MPV_KEY_BY_CODE: Record = { Tab: 'TAB', }; +const MPV_MOUSE_BUTTON_BY_BUTTON: Record = { + 0: 'MBTN_LEFT', + 1: 'MBTN_MID', + 2: 'MBTN_RIGHT', + 3: 'MBTN_BACK', + 4: 'MBTN_FORWARD', +}; + function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean { return JSON.stringify(a) === JSON.stringify(b); } @@ -152,6 +168,24 @@ export function keyboardEventToConfigKey( return [...parts, input.code].join('+'); } +export function mouseEventToConfigKey(input: MouseInputLike, mode: KeyInputMode): string | null { + if (mode !== 'dom-code') { + return null; + } + + const key = MPV_MOUSE_BUTTON_BY_BUTTON[input.button]; + if (!key) { + return null; + } + + const parts: string[] = []; + if (input.ctrlKey) parts.push('Ctrl'); + if (input.altKey) parts.push('Alt'); + if (input.shiftKey) parts.push('Shift'); + if (input.metaKey) parts.push('Meta'); + return [...parts, key].join('+'); +} + export function createMpvKeybindingRows( defaultBindings: Keybinding[], userBindings: unknown, diff --git a/src/settings/settings-keybinding-controls.test.ts b/src/settings/settings-keybinding-controls.test.ts new file mode 100644 index 00000000..43dfbacf --- /dev/null +++ b/src/settings/settings-keybinding-controls.test.ts @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { shouldUseLearnedMouseBinding } from './settings-keybinding-controls'; + +test('mouse key learning ignores primary left clicks in DOM-code mode', () => { + const learnButton = {}; + const outsideTarget = {}; + + assert.equal( + shouldUseLearnedMouseBinding( + 'MBTN_LEFT', + 'dom-code', + { button: 0, target: outsideTarget } as MouseEvent, + learnButton as HTMLButtonElement, + ), + false, + ); + assert.equal( + shouldUseLearnedMouseBinding( + 'MBTN_BACK', + 'dom-code', + { button: 3, target: outsideTarget } as MouseEvent, + learnButton as HTMLButtonElement, + ), + true, + ); +}); + +test('mouse key learning still ignores primary learn-button activation', () => { + const learnButton = {}; + + assert.equal( + shouldUseLearnedMouseBinding( + 'MBTN_LEFT', + 'dom-code', + { button: 0, target: learnButton } as MouseEvent, + learnButton as HTMLButtonElement, + ), + false, + ); + assert.equal( + shouldUseLearnedMouseBinding( + 'MBTN_BACK', + 'dom-code', + { button: 3, target: learnButton } as MouseEvent, + learnButton as HTMLButtonElement, + ), + true, + ); +}); diff --git a/src/settings/settings-keybinding-controls.ts b/src/settings/settings-keybinding-controls.ts index 6310aa10..e740f3c8 100644 --- a/src/settings/settings-keybinding-controls.ts +++ b/src/settings/settings-keybinding-controls.ts @@ -4,6 +4,7 @@ import { buildMpvKeybindingConfigValue, createMpvKeybindingRows, keyboardEventToConfigKey, + mouseEventToConfigKey, parseMpvCommandText, type KeyInputMode, type MpvKeybindingRow, @@ -18,6 +19,18 @@ export function configureKeybindingControls(options: { requestRender: () => void requestRender = options.requestRender; } +export function shouldUseLearnedMouseBinding( + next: string, + mode: KeyInputMode, + event: MouseEvent, + button: HTMLButtonElement, +): boolean { + return Boolean( + !(event.target === button && event.button === 0) && + !(mode === 'dom-code' && event.button === 0), + ); +} + function startKeyLearning( button: HTMLButtonElement, mode: KeyInputMode, @@ -58,6 +71,15 @@ function startKeyLearning( }; onBlur = (): void => stop(); onMouseDown = (event: MouseEvent): void => { + const next = mouseEventToConfigKey(event, mode); + if (next && shouldUseLearnedMouseBinding(next, mode, event, button)) { + event.preventDefault(); + event.stopPropagation(); + stop(); + onValue(next); + return; + } + if (event.target !== button) { stop(); }