feat(config): show default keybindings in generated example config

- Expand `keybindings` array in `config.example.jsonc` to list all built-in defaults instead of `[]`
- Inject `DEFAULT_KEYBINDINGS` into template when resolved config has an empty keybindings array
- Add regression tests for template parity, default binding compile/action mapping, overlay keyboard dispatch, and mpv plugin registration/dispatch
- Add fullscreen binding to docs shortcut tables and clarify keybindings can target mpv commands or session actions
This commit is contained in:
2026-05-12 22:58:58 -07:00
parent 430373f010
commit c1ee0dfd2e
12 changed files with 665 additions and 40 deletions
+16 -1
View File
@@ -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 {
@@ -2217,3 +2223,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);
});
+1 -1
View File
@@ -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: [
+14 -1
View File
@@ -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,
),
@@ -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<string, string> = {
[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({
+101
View File
@@ -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<void> {
});
}
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<string, number>();
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;
@@ -709,6 +764,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<string, string> = {
[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();