feat(keybindings): add mouse button support for mpv keybindings (#103)

This commit is contained in:
2026-05-31 22:22:38 -07:00
committed by GitHub
parent e6a004ab8b
commit 487143802a
14 changed files with 281 additions and 5 deletions
@@ -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({
+18 -2
View File
@@ -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<ConfiguredShortcuts, 'multiCopyTimeoutMs'>;
@@ -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,
+41 -2
View File
@@ -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();
+28
View File
@@ -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<number, string> = {
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<ShortcutsConfig>,
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
@@ -57,6 +57,11 @@ const KEY_NAME_MAP: Record<string, string> = {
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 {
+18
View File
@@ -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, []);
+34
View File
@@ -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<string, string> = {
Tab: 'TAB',
};
const MPV_MOUSE_BUTTON_BY_BUTTON: Record<number, string> = {
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,
@@ -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,
);
});
@@ -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();
}