diff --git a/backlog/tasks/task-358 - Show-and-verify-default-keybindings-in-example-config.md b/backlog/tasks/task-358 - Show-and-verify-default-keybindings-in-example-config.md new file mode 100644 index 00000000..8a885cf3 --- /dev/null +++ b/backlog/tasks/task-358 - Show-and-verify-default-keybindings-in-example-config.md @@ -0,0 +1,55 @@ +--- +id: TASK-358 +title: Show and verify default keybindings in example config +status: Done +assignee: + - '@Codex' +created_date: '2026-05-13 03:33' +updated_date: '2026-05-13 03:45' +labels: + - config + - keybindings + - overlay + - mpv +dependencies: [] +priority: medium +--- + +## Description + + +Keep the shipped example configuration, overlay runtime, and mpv plugin aligned with the built-in default keybindings. The example `keybindings` array should show the same defaults that are active by default, and focused tests should catch drift between documented defaults and actual overlay/mpv wiring. + + +## Acceptance Criteria + +- [x] #1 `config.example.jsonc` and the docs-site copy show all default keybindings in the `keybindings` array. +- [x] #2 Default keybindings are registered without conflicts in the overlay session-binding path. +- [x] #3 Default keybindings are registered and dispatched correctly inside the mpv plugin. +- [x] #4 Focused regression tests cover default keybinding/config-example parity and mpv/plugin dispatch. + + +## Implementation Plan + + +1. Inspect default keybinding definitions, config-example generation, overlay shortcut/session-binding tests, and mpv plugin binding tests. +2. Add failing tests for config-example keybinding parity and any missing default overlay/mpv wiring. +3. Update generated/example config and source wiring only where tests show drift. +4. Run focused Bun/Lua tests, regenerate examples if needed, update task AC/final notes. + + +## Implementation Notes + + +Implemented the config-template path by injecting `DEFAULT_KEYBINDINGS` into generated examples when the resolved config has an empty `keybindings` array, preserving runtime merge semantics. Added coverage for template parity, default binding compile/action mapping, overlay keyboard dispatch, and mpv plugin registration/dispatch. Regenerated both `config.example.jsonc` artifacts and added changelog fragment `changes/358-default-keybindings-config-example.md`. + + +## Final Summary + + +Updated generated example configuration so `config.example.jsonc` and `docs-site/public/config.example.jsonc` now show every built-in default keybinding in the `keybindings` array instead of `[]`. The template copy now describes the array as default plus custom keybindings, while runtime default merge behavior remains unchanged. + +Added regression coverage that the generated template parses back to `DEFAULT_KEYBINDINGS`, that every default binding compiles to the expected mpv command or session action, that the overlay keyboard handler dispatches all compiled defaults, and that the mpv plugin registers and invokes default mpv/session-action bindings. Also updated docs tables to include the default fullscreen binding and clarified that keybindings can target mpv commands or SubMiner session actions. + +Verification passed: `bun run format:check:src`, `bun run changelog:lint`, `bun run docs:test`, `bun run docs:build`, `bun run verify:config-example`, focused config/session/renderer/plugin tests, `bun run typecheck`, `bun run test:env`, `bun run test:fast`, `bun run build`, and `bun run test:smoke:dist`. + diff --git a/changes/358-default-keybindings-config-example.md b/changes/358-default-keybindings-config-example.md new file mode 100644 index 00000000..54f2adec --- /dev/null +++ b/changes/358-default-keybindings-config-example.md @@ -0,0 +1,4 @@ +type: changed +area: config + +- Config: Expanded the generated example config so `keybindings` lists every built-in default and added regression coverage that those defaults compile, dispatch in the overlay, and register through the mpv plugin. diff --git a/config.example.jsonc b/config.example.jsonc index b4495b9c..3a835be2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -183,11 +183,130 @@ // ========================================== // Keybindings (MPV Commands) - // Extra keybindings that are merged with built-in defaults. + // Default and custom keybindings that are merged with built-in defaults. // Set command to null to disable a default keybinding. // Hot-reload: keybinding changes apply live and update the session help modal on reopen. // ========================================== - "keybindings": [], // Extra keybindings that are merged with built-in defaults. + "keybindings": [ + { + "key": "Space", // Key setting. + "command": [ + "cycle", + "pause" + ] // Command setting. + }, + { + "key": "KeyF", // Key setting. + "command": [ + "cycle", + "fullscreen" + ] // Command setting. + }, + { + "key": "KeyJ", // Key setting. + "command": [ + "cycle", + "sid" + ] // Command setting. + }, + { + "key": "Shift+KeyJ", // Key setting. + "command": [ + "cycle", + "secondary-sid" + ] // Command setting. + }, + { + "key": "ArrowRight", // Key setting. + "command": [ + "seek", + 5 + ] // Command setting. + }, + { + "key": "ArrowLeft", // Key setting. + "command": [ + "seek", + -5 + ] // Command setting. + }, + { + "key": "ArrowUp", // Key setting. + "command": [ + "seek", + 60 + ] // Command setting. + }, + { + "key": "ArrowDown", // Key setting. + "command": [ + "seek", + -60 + ] // Command setting. + }, + { + "key": "Shift+KeyH", // Key setting. + "command": [ + "sub-seek", + -1 + ] // Command setting. + }, + { + "key": "Shift+KeyL", // Key setting. + "command": [ + "sub-seek", + 1 + ] // Command setting. + }, + { + "key": "Shift+BracketRight", // Key setting. + "command": [ + "__sub-delay-next-line" + ] // Command setting. + }, + { + "key": "Shift+BracketLeft", // Key setting. + "command": [ + "__sub-delay-prev-line" + ] // Command setting. + }, + { + "key": "Ctrl+Alt+KeyC", // Key setting. + "command": [ + "__youtube-picker-open" + ] // Command setting. + }, + { + "key": "Ctrl+Alt+KeyP", // Key setting. + "command": [ + "__playlist-browser-open" + ] // Command setting. + }, + { + "key": "Ctrl+Shift+KeyH", // Key setting. + "command": [ + "__replay-subtitle" + ] // Command setting. + }, + { + "key": "Ctrl+Shift+KeyL", // Key setting. + "command": [ + "__play-next-subtitle" + ] // Command setting. + }, + { + "key": "KeyQ", // Key setting. + "command": [ + "quit" + ] // Command setting. + }, + { + "key": "Ctrl+KeyW", // Key setting. + "command": [ + "quit" + ] // Command setting. + } + ], // Default and custom keybindings that are merged with built-in defaults. // ========================================== // Secondary Subtitles diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 5654bfbe..fae7f0a8 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -461,7 +461,7 @@ See `config.example.jsonc` for detailed configuration options. ### Keybindings -Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: +Add a `keybindings` array to configure keyboard shortcuts that send mpv commands or SubMiner session actions: See `config.example.jsonc` for detailed configuration options and more examples. @@ -470,6 +470,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. | Key | Command | Description | | -------------------- | ----------------------------- | --------------------------------------- | | `Space` | `["cycle", "pause"]` | Toggle pause | +| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index b4495b9c..3a835be2 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -183,11 +183,130 @@ // ========================================== // Keybindings (MPV Commands) - // Extra keybindings that are merged with built-in defaults. + // Default and custom keybindings that are merged with built-in defaults. // Set command to null to disable a default keybinding. // Hot-reload: keybinding changes apply live and update the session help modal on reopen. // ========================================== - "keybindings": [], // Extra keybindings that are merged with built-in defaults. + "keybindings": [ + { + "key": "Space", // Key setting. + "command": [ + "cycle", + "pause" + ] // Command setting. + }, + { + "key": "KeyF", // Key setting. + "command": [ + "cycle", + "fullscreen" + ] // Command setting. + }, + { + "key": "KeyJ", // Key setting. + "command": [ + "cycle", + "sid" + ] // Command setting. + }, + { + "key": "Shift+KeyJ", // Key setting. + "command": [ + "cycle", + "secondary-sid" + ] // Command setting. + }, + { + "key": "ArrowRight", // Key setting. + "command": [ + "seek", + 5 + ] // Command setting. + }, + { + "key": "ArrowLeft", // Key setting. + "command": [ + "seek", + -5 + ] // Command setting. + }, + { + "key": "ArrowUp", // Key setting. + "command": [ + "seek", + 60 + ] // Command setting. + }, + { + "key": "ArrowDown", // Key setting. + "command": [ + "seek", + -60 + ] // Command setting. + }, + { + "key": "Shift+KeyH", // Key setting. + "command": [ + "sub-seek", + -1 + ] // Command setting. + }, + { + "key": "Shift+KeyL", // Key setting. + "command": [ + "sub-seek", + 1 + ] // Command setting. + }, + { + "key": "Shift+BracketRight", // Key setting. + "command": [ + "__sub-delay-next-line" + ] // Command setting. + }, + { + "key": "Shift+BracketLeft", // Key setting. + "command": [ + "__sub-delay-prev-line" + ] // Command setting. + }, + { + "key": "Ctrl+Alt+KeyC", // Key setting. + "command": [ + "__youtube-picker-open" + ] // Command setting. + }, + { + "key": "Ctrl+Alt+KeyP", // Key setting. + "command": [ + "__playlist-browser-open" + ] // Command setting. + }, + { + "key": "Ctrl+Shift+KeyH", // Key setting. + "command": [ + "__replay-subtitle" + ] // Command setting. + }, + { + "key": "Ctrl+Shift+KeyL", // Key setting. + "command": [ + "__play-next-subtitle" + ] // Command setting. + }, + { + "key": "KeyQ", // Key setting. + "command": [ + "quit" + ] // Command setting. + }, + { + "key": "Ctrl+KeyW", // Key setting. + "command": [ + "quit" + ] // Command setting. + } + ], // Default and custom keybindings that are merged with built-in defaults. // ========================================== // Secondary Subtitles diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 8b4e263d..75a0bebb 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -38,6 +38,7 @@ These control playback and subtitle display. They require overlay window focus. | Shortcut | Action | | -------------------- | --------------------------------------------------- | | `Space` | Toggle mpv pause | +| `F` | Toggle fullscreen | | `V` | Toggle primary subtitle bar visibility | | `J` | Cycle primary subtitle track | | `Shift+J` | Cycle secondary subtitle track | diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 40552dd5..713e1411 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -21,6 +21,7 @@ local recorded = { bindings = {}, removed = {}, async_calls = {}, + mpv_commands = {}, osd = {}, } @@ -38,6 +39,10 @@ function mp.remove_key_binding(name) recorded.removed[#recorded.removed + 1] = name end +function mp.commandv(...) + recorded.mpv_commands[#recorded.mpv_commands + 1] = { ... } +end + function mp.add_timeout(seconds, callback) return { seconds = seconds, @@ -73,19 +78,75 @@ local ctx = { }, { key = { - code = "KeyL", - modifiers = { "ctrl", "shift" }, + code = "Space", + modifiers = {}, }, - actionType = "session-action", - actionId = "playNextSubtitle", + actionType = "mpv-command", + command = { "cycle", "pause" }, }, { key = { - code = "KeyA", - modifiers = { "alt", "meta" }, + code = "KeyF", + modifiers = {}, }, - actionType = "session-action", - actionId = "openCharacterDictionary", + actionType = "mpv-command", + command = { "cycle", "fullscreen" }, + }, + { + key = { + code = "KeyJ", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "cycle", "sid" }, + }, + { + key = { + code = "KeyJ", + modifiers = { "shift" }, + }, + actionType = "mpv-command", + command = { "cycle", "secondary-sid" }, + }, + { + key = { + code = "ArrowRight", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "seek", 5 }, + }, + { + key = { + code = "ArrowLeft", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "seek", -5 }, + }, + { + key = { + code = "ArrowUp", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "seek", 60 }, + }, + { + key = { + code = "ArrowDown", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "seek", -60 }, + }, + { + key = { + code = "KeyH", + modifiers = { "shift" }, + }, + actionType = "mpv-command", + command = { "sub-seek", -1 }, }, { key = { @@ -95,6 +156,78 @@ local ctx = { actionType = "mpv-command", command = { "sub-seek", 1 }, }, + { + key = { + code = "BracketRight", + modifiers = { "shift" }, + }, + actionType = "session-action", + actionId = "shiftSubDelayNextLine", + }, + { + key = { + code = "BracketLeft", + modifiers = { "shift" }, + }, + actionType = "session-action", + actionId = "shiftSubDelayPrevLine", + }, + { + key = { + code = "KeyC", + modifiers = { "ctrl", "alt" }, + }, + actionType = "session-action", + actionId = "openYoutubePicker", + }, + { + key = { + code = "KeyP", + modifiers = { "ctrl", "alt" }, + }, + actionType = "session-action", + actionId = "openPlaylistBrowser", + }, + { + key = { + code = "KeyH", + modifiers = { "ctrl", "shift" }, + }, + actionType = "session-action", + actionId = "replayCurrentSubtitle", + }, + { + key = { + code = "KeyL", + modifiers = { "ctrl", "shift" }, + }, + actionType = "session-action", + actionId = "playNextSubtitle", + }, + { + key = { + code = "KeyQ", + modifiers = {}, + }, + actionType = "mpv-command", + command = { "quit" }, + }, + { + key = { + code = "KeyW", + modifiers = { "ctrl" }, + }, + actionType = "mpv-command", + command = { "quit" }, + }, + { + key = { + code = "KeyA", + modifiers = { "alt", "meta" }, + }, + actionType = "session-action", + actionId = "openCharacterDictionary", + }, }, }, nil end, @@ -129,31 +262,66 @@ local ctx = { local bindings = session_bindings.create(ctx) assert_true(bindings.register_bindings(), "session bindings should register") -local starter = nil -for _, binding in ipairs(recorded.bindings) do - if binding.keys == "Ctrl+S" then - starter = binding - break +local function find_binding(keys) + for _, binding in ipairs(recorded.bindings) do + if binding.keys == keys then + return binding + end end + return nil end + +local starter = find_binding("Ctrl+S") assert_true(starter ~= nil, "multi-mine starter binding should be registered") -local play_next = nil -for _, binding in ipairs(recorded.bindings) do - if binding.keys == "Ctrl+L" then - play_next = binding - break +local expected_mpv_bindings = { + { keys = "SPACE", command = { "cycle", "pause" } }, + { keys = "f", command = { "cycle", "fullscreen" } }, + { keys = "j", command = { "cycle", "sid" } }, + { keys = "J", command = { "cycle", "secondary-sid" } }, + { keys = "RIGHT", command = { "seek", 5 } }, + { keys = "LEFT", command = { "seek", -5 } }, + { keys = "UP", command = { "seek", 60 } }, + { keys = "DOWN", command = { "seek", -60 } }, + { keys = "H", command = { "sub-seek", -1 } }, + { keys = "L", command = { "sub-seek", 1 } }, + { keys = "q", command = { "quit" } }, + { keys = "Ctrl+w", command = { "quit" } }, +} + +for _, expected in ipairs(expected_mpv_bindings) do + local binding = find_binding(expected.keys) + assert_true(binding ~= nil, "default mpv binding should register " .. expected.keys) + binding.fn() + local command = recorded.mpv_commands[#recorded.mpv_commands] + assert_true(command ~= nil, "default mpv binding should invoke mpv command " .. expected.keys) + for index, value in ipairs(expected.command) do + assert_true(command[index] == value, "default mpv command mismatch for " .. expected.keys) end end + +local expected_cli_bindings = { + { keys = "Shift+]", flag = "--shift-sub-delay-next-line" }, + { keys = "Shift+[", flag = "--shift-sub-delay-prev-line" }, + { keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" }, + { keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" }, + { keys = "Ctrl+H", flag = "--replay-current-subtitle" }, + { keys = "Ctrl+L", flag = "--play-next-subtitle" }, +} + +for _, expected in ipairs(expected_cli_bindings) do + local binding = find_binding(expected.keys) + assert_true(binding ~= nil, "default session action should register " .. expected.keys) + binding.fn() + local cli_call = recorded.async_calls[#recorded.async_calls] + assert_true(cli_call ~= nil, "default session action should invoke CLI " .. expected.keys) + assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag) +end + +local play_next = find_binding("Ctrl+L") assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form") -local subtitle_jump = nil -for _, binding in ipairs(recorded.bindings) do - if binding.keys == "L" then - subtitle_jump = binding - break - end -end +local subtitle_jump = find_binding("L") assert_true(subtitle_jump ~= nil, "shifted subtitle jump binding should use mpv uppercase letter form") play_next.fn() @@ -161,13 +329,7 @@ local play_next_call = recorded.async_calls[#recorded.async_calls] assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action") assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag") -local character_dictionary = nil -for _, binding in ipairs(recorded.bindings) do - if binding.keys == "Alt+Meta+a" then - character_dictionary = binding - break - end -end +local character_dictionary = find_binding("Alt+Meta+a") assert_true(character_dictionary ~= nil, "character dictionary binding should be registered") character_dictionary.fn() diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 31b95c64..d20fe05e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -4,7 +4,13 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { ConfigService, ConfigStartupParseError } from './service'; -import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY, deepMergeRawConfig } from './definitions'; +import { + DEFAULT_CONFIG, + DEFAULT_KEYBINDINGS, + RUNTIME_OPTION_REGISTRY, + deepMergeRawConfig, +} from './definitions'; +import { parseConfigContent } from './parse'; import { generateConfigTemplate } from './template'; function makeTempDir(): string { @@ -2218,3 +2224,12 @@ test('template generator includes known keys', () => { /"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/, ); }); + +test('template generator shows built-in default keybindings in the keybindings array', () => { + const output = generateConfigTemplate(DEFAULT_CONFIG); + const parsed = parseConfigContent('config.example.jsonc', output) as { + keybindings?: unknown; + }; + + assert.deepEqual(parsed.keybindings, DEFAULT_KEYBINDINGS); +}); diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index f3f21098..7b3cbab6 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -62,7 +62,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'Keybindings (MPV Commands)', description: [ - 'Extra keybindings that are merged with built-in defaults.', + 'Default and custom keybindings that are merged with built-in defaults.', 'Set command to null to disable a default keybinding.', ], notes: [ diff --git a/src/config/template.ts b/src/config/template.ts index 42bf448c..15d966dd 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -3,6 +3,7 @@ import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG, + DEFAULT_KEYBINDINGS, deepCloneConfig, } from './definitions'; @@ -103,9 +104,21 @@ function renderSection( return lines.join('\n'); } +function createTemplateConfig(config: ResolvedConfig): ResolvedConfig { + const templateConfig = deepCloneConfig(config); + if (templateConfig.keybindings.length === 0) { + templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({ + key: binding.key, + command: binding.command === null ? null : [...binding.command], + })); + } + return templateConfig; +} + export function generateConfigTemplate( config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG), ): string { + const templateConfig = createTemplateConfig(config); const lines: string[] = []; lines.push('/**'); lines.push(' * SubMiner Example Configuration File'); @@ -123,7 +136,7 @@ export function generateConfigTemplate( lines.push( renderSection( section.key, - config[section.key], + templateConfig[section.key], index === CONFIG_TEMPLATE_SECTIONS.length - 1, comments, ), diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 932e4843..2b19192e 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -209,6 +209,41 @@ test('compileSessionBindings keeps default replay and next subtitle session acti assert.equal(next?.actionId, 'playNextSubtitle'); }); +test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => { + const expectedSpecialActions: Record = { + [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine', + [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine', + [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', + [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', + [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', + [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle', + }; + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: DEFAULT_KEYBINDINGS, + platform: 'linux', + }); + + assert.deepEqual(result.warnings, []); + const byOriginalKey = new Map(result.bindings.map((binding) => [binding.originalKey, binding])); + assert.equal(byOriginalKey.size, DEFAULT_KEYBINDINGS.length); + + for (const defaultBinding of DEFAULT_KEYBINDINGS) { + const compiled = byOriginalKey.get(defaultBinding.key); + assert.ok(compiled, `${defaultBinding.key} should compile`); + + const specialAction = expectedSpecialActions[String(defaultBinding.command?.[0])]; + if (specialAction) { + assert.equal(compiled.actionType, 'session-action'); + assert.equal(compiled.actionId, specialAction); + continue; + } + + assert.equal(compiled.actionType, 'mpv-command'); + assert.deepEqual(compiled.command, defaultBinding.command); + } +}); + test('compileSessionBindings omits disabled bindings', () => { const result = compileSessionBindings({ shortcuts: createShortcuts({ diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 0ec1e197..ca26f4e5 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -4,6 +4,9 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; import type { CompiledSessionBinding } from '../../types'; +import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; +import { compileSessionBindings } from '../../core/services/session-bindings'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js'; type CommandEventDetail = { @@ -40,6 +43,58 @@ function wait(ms: number): Promise { }); } +function eventFromKeyString(keyString: string): { + key: string; + code: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} { + const parts = keyString.split('+'); + const code = parts.pop() ?? ''; + return { + key: code === 'Space' ? ' ' : code, + code, + ctrlKey: parts.includes('Ctrl'), + metaKey: parts.includes('Meta'), + altKey: parts.includes('Alt'), + shiftKey: parts.includes('Shift'), + }; +} + +function countedJsonValues(values: unknown[]): Array<[string, number]> { + const counts = new Map(); + for (const value of values) { + const key = JSON.stringify(value); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)); +} + +function createEmptyShortcuts(): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: null, + copySubtitle: null, + copySubtitleMultiple: null, + updateLastCardFromClipboard: null, + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: null, + mineSentenceMultiple: null, + multiCopyTimeoutMs: 3000, + toggleSecondarySub: null, + markAudioCard: null, + openCharacterDictionary: null, + openRuntimeOptions: null, + openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, + }; +} + function installKeyboardTestGlobals() { const previousWindow = (globalThis as { window?: unknown }).window; const previousDocument = (globalThis as { document?: unknown }).document; @@ -725,6 +780,52 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => { } }); +test('default keybindings dispatch through overlay keyboard handling', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + const specialActionIds: Record = { + [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine', + [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine', + [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', + [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser', + [SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle', + [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle', + }; + const compiled = compileSessionBindings({ + shortcuts: createEmptyShortcuts(), + keybindings: DEFAULT_KEYBINDINGS, + platform: 'linux', + }); + + try { + assert.deepEqual(compiled.warnings, []); + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings(compiled.bindings); + + for (const binding of DEFAULT_KEYBINDINGS) { + testGlobals.dispatchKeydown(eventFromKeyString(binding.key)); + } + await wait(0); + + const expectedMpvCommands = DEFAULT_KEYBINDINGS.filter( + (binding) => !specialActionIds[String(binding.command?.[0])], + ).map((binding) => binding.command); + const expectedSessionActions = DEFAULT_KEYBINDINGS.map( + (binding) => specialActionIds[String(binding.command?.[0])], + ).filter(Boolean); + + assert.deepEqual( + countedJsonValues(testGlobals.mpvCommands), + countedJsonValues(expectedMpvCommands), + ); + assert.deepEqual( + testGlobals.sessionActions.map((action) => action.actionId).sort(), + expectedSessionActions.sort(), + ); + } finally { + testGlobals.restore(); + } +}); + test('paused configured subtitle-jump keybinding re-applies pause after backward seek', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness();