Controller: hold to show the secondary subtitle, dictionary entry navigation, standard button names (#312)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-01 15:54:47 -08:00
1 parent af44735bb8
commit 655510cf2e
24 files changed
+609 -33

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added a controller binding that shows the secondary subtitle only while a button is held (`controller.bindings.holdSecondarySub`). It is unbound by default; assign it with Learn in the `Alt+C` controller modal. It works with or without keyboard-only mode and does not change the saved secondary subtitle mode.
- Added controller bindings to select the previous or next word entry in the open Yomitan popup (`controller.bindings.previousEntry` / `nextEntry`). Mine Card mines the selected entry. Both are unbound by default; assign them with Learn in the `Alt+C` controller modal.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The `Alt+C` controller screen now shows the right button names for controllers that report the W3C standard layout (most Xbox, PlayStation, Switch and 8BitDo pads). Previously Select and Start showed as `L3` and `R3`, and the triggers as `Back` and `Start`. Bindings themselves were unaffected.
+9
View File
@@ -159,6 +159,15 @@
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
+1 -1
View File
@@ -290,7 +290,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
### Controller support
Gamepad input for the overlay, through the browser Gamepad API. It only works while keyboard-only mode is on. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode and the secondary subtitle hold (`controller.bindings.holdSecondarySub`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
| Key | Default | What it does |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
+9
View File
@@ -159,6 +159,15 @@
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
+21 -14
View File
@@ -157,25 +157,32 @@ You can drive the overlay with a gamepad.
1. Set `controller.enabled` to `true` in your config.
2. Connect a controller. SubMiner uses the first one it sees.
3. Press `Y` on the controller to turn on keyboard-only mode. The controller only works in this mode.
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads.
| Button | Action |
| --------------------- | ------------------------------------ |
| `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup |
| `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv |
| `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup |
| Button | Action |
| --------------------- | -------------------------------------- |
| `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup |
| `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv |
| `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup |
| Unbound by default | Show the secondary subtitle while held |
| Unbound by default | Previous word entry in the popup |
| Unbound by default | Next word entry in the popup |
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
## Development profiles
+3
View File
@@ -79,6 +79,9 @@ export const CORE_DEFAULT_CONFIG: Pick<
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+18
View File
@@ -55,6 +55,24 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding descriptor for toggling mpv play/pause.',
},
{
id: 'holdSecondarySub',
defaultValue: defaultConfig.controller.bindings.holdSecondarySub,
description:
'Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode.',
},
{
id: 'previousEntry',
defaultValue: defaultConfig.controller.bindings.previousEntry,
description:
'Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry.',
},
{
id: 'nextEntry',
defaultValue: defaultConfig.controller.bindings.nextEntry,
description:
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
},
] as const;
const axisBindings = [
+3
View File
@@ -88,6 +88,9 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
'holdSecondarySub',
'previousEntry',
'nextEntry',
] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [
+3
View File
@@ -70,6 +70,9 @@ function createControllerConfigFixture() {
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
holdSecondarySub: { kind: 'none' as const },
previousEntry: { kind: 'none' as const },
nextEntry: { kind: 'none' as const },
leftStickHorizontal: {
kind: 'axis' as const,
axisIndex: 0,
@@ -67,6 +67,8 @@ const VOID_ACTIONS = [
'previousAudio',
'nextAudio',
'playCurrentAudio',
'previousEntry',
'nextEntry',
'toggleMpvPause',
] as const;
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
@@ -87,6 +89,9 @@ function createHarness(
} = {},
) {
const calls: HarnessCall[] = [];
// Hold changes are kept apart from `calls` so tests that press R2 for other
// actions keep their exact call lists.
const secondarySubHeld: boolean[] = [];
const states: ControllerRuntimeSnapshot[] = [];
const deps = options.deps ?? {};
const { gamepads = [], config = createControllerConfig() } = options;
@@ -106,7 +111,10 @@ function createHarness(
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
previousEntry: () => {},
nextEntry: () => {},
toggleMpvPause: () => {},
setSecondarySubHeld: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
...deps,
@@ -121,6 +129,10 @@ function createHarness(
deps[action]?.();
};
}
controllerOptions.setSecondarySubHeld = (held) => {
secondarySubHeld.push(held);
deps.setSecondarySubHeld?.(held);
};
controllerOptions.moveSelection = (delta) => {
calls.push(`moveSelection:${delta}`);
deps.moveSelection?.(delta);
@@ -137,6 +149,7 @@ function createHarness(
const controller = createGamepadController(controllerOptions);
return {
calls,
secondarySubHeld,
states,
controller,
poll: (...times: number[]) => {
@@ -432,3 +445,217 @@ test('gamepad controller maps L3 to mpv pause and keeps unbound audio action ina
assert.deepEqual(harness.calls, ['toggleMpvPause']);
});
const HOLD_ON_R2 = { holdSecondarySub: { kind: 'button', buttonIndex: 7 } } as const;
test('gamepad controller leaves the secondary subtitle hold unbound by default', () => {
const harness = createHarness({
gamepads: [
createGamepad('pad-1', {
buttons: Object.fromEntries(Array.from({ length: 16 }, (_, i) => [i, PRESSED])),
}),
],
});
harness.poll(0);
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.holdSecondarySub, { kind: 'none' });
assert.deepEqual(harness.secondarySubHeld, []);
});
test('gamepad controller shows the secondary subtitle only while R2 is held, even outside keyboard-only mode', () => {
let r2: TestButton = RELEASED;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: { 7: r2 } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
deps: { getKeyboardModeEnabled: () => false },
});
harness.poll(0);
r2 = { value: 1, pressed: true, touched: true };
harness.poll(16, 32, 48);
r2 = RELEASED;
harness.poll(64, 80);
assert.deepEqual(harness.secondarySubHeld, [true, false]);
assert.deepEqual(harness.calls, []);
});
test('gamepad controller releases the secondary subtitle hold when the pad disconnects', () => {
let connected = true;
const harness = createHarness({
gamepads: () => (connected ? [createGamepad('pad-1', { buttons: { 7: PRESSED } })] : []),
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
});
harness.poll(0);
connected = false;
harness.poll(16);
assert.deepEqual(harness.secondarySubHeld, [true, false]);
});
test('gamepad controller releases the secondary subtitle hold when a blocking modal opens', () => {
let blocked = false;
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 7: PRESSED } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
deps: { getInteractionBlocked: () => blocked },
});
harness.poll(0);
blocked = true;
harness.poll(16);
blocked = false;
harness.poll(32);
assert.deepEqual(harness.secondarySubHeld, [true, false, true]);
});
test('gamepad controller ignores the secondary subtitle hold while controller support is disabled', () => {
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 7: PRESSED } })],
config: createControllerConfig({ enabled: false, bindings: HOLD_ON_R2 }),
});
harness.poll(0, 16);
assert.deepEqual(harness.secondarySubHeld, []);
});
test('gamepad controller honors a remapped or explicitly unbound secondary subtitle hold', () => {
const remapped = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 5: PRESSED, 7: PRESSED } })],
config: createControllerConfig({
bindings: { holdSecondarySub: { kind: 'button', buttonIndex: 5 } },
}),
});
remapped.poll(0);
assert.deepEqual(remapped.secondarySubHeld, [true]);
const unbound = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 7: PRESSED } })],
config: createControllerConfig({ bindings: { holdSecondarySub: { kind: 'none' } } }),
});
unbound.poll(0);
assert.deepEqual(unbound.secondarySubHeld, []);
});
test('gamepad controller requires an analog R2 pull past the trigger deadzone to hold', () => {
let r2: TestButton = { value: 0.3, pressed: false, touched: true };
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: { 7: r2 } })],
config: createControllerConfig({
triggerInputMode: 'analog',
triggerDeadzone: 0.5,
bindings: HOLD_ON_R2,
}),
});
harness.poll(0);
assert.deepEqual(harness.secondarySubHeld, []);
r2 = { value: 0.7, pressed: false, touched: true };
harness.poll(16);
assert.deepEqual(harness.secondarySubHeld, [true]);
});
const ENTRY_ON_DPAD = {
previousEntry: { kind: 'button', buttonIndex: 12 },
nextEntry: { kind: 'button', buttonIndex: 13 },
// Free the D-pad from the popup-scroll fallback, as a user binding it would.
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'none' },
} as const;
test('gamepad controller leaves popup entry navigation unbound by default', () => {
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.previousEntry, { kind: 'none' });
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.nextEntry, { kind: 'none' });
});
test('gamepad controller moves one popup entry per press while the lookup is open', () => {
let buttons: Record<number, TestButton> = {};
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => true },
});
harness.poll(0);
buttons = { 13: PRESSED };
// Holding the button does not repeat.
harness.poll(16, 32, 400, 800);
buttons = {};
harness.poll(816);
buttons = { 13: PRESSED };
harness.poll(832);
buttons = {};
harness.poll(848);
buttons = { 12: PRESSED };
harness.poll(864);
assert.deepEqual(harness.calls, ['nextEntry', 'nextEntry', 'previousEntry']);
});
test('gamepad controller counts a popup press right as the popup reopens after a release', () => {
let buttons: Record<number, TestButton> = {};
let lookupOpen = true;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => lookupOpen },
});
harness.poll(0);
buttons = { 13: PRESSED };
harness.poll(16);
// The popup closes while the button is still down, and the button is let go meanwhile.
lookupOpen = false;
harness.poll(32);
buttons = {};
harness.poll(48);
// The popup reopens on the same frame the button is pressed again.
lookupOpen = true;
buttons = { 13: PRESSED };
harness.poll(64);
assert.deepEqual(harness.calls, ['nextEntry', 'nextEntry']);
});
test('gamepad controller waits for a fresh press when the popup opens under a held button', () => {
let lookupOpen = false;
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 13: PRESSED } })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => lookupOpen },
});
harness.poll(0, 16);
lookupOpen = true;
harness.poll(32, 48);
assert.deepEqual(harness.calls, []);
});
test('gamepad controller ignores popup entry navigation while the lookup is closed', () => {
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 12: PRESSED, 13: PRESSED } })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => false },
});
harness.poll(0, 16);
assert.deepEqual(harness.calls, []);
});
test('gamepad controller requires keyboard-only mode for popup entry navigation', () => {
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 13: PRESSED } })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => true, getKeyboardModeEnabled: () => false },
});
harness.poll(0, 16);
assert.deepEqual(harness.calls, []);
});
@@ -37,7 +37,10 @@ type GamepadControllerOptions = {
previousAudio: () => void;
nextAudio: () => void;
playCurrentAudio: () => void;
previousEntry: () => void;
nextEntry: () => void;
toggleMpvPause: () => void;
setSecondarySubHeld: (held: boolean) => void;
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
onState: (state: ControllerRuntimeSnapshot) => void;
@@ -244,12 +247,22 @@ function resolveAxisBindingValue(
return axisValue;
}
/** Discrete actions that only act while the lookup popup is open. */
const POPUP_ONLY_ACTIONS = [
'previousAudio',
'nextAudio',
'playCurrentAudio',
'previousEntry',
'nextEntry',
] as const;
export function createGamepadController(options: GamepadControllerOptions) {
let previousActions = new Map<string, boolean>();
let selectionHold = createHoldState();
let jumpHold = createHoldState();
let activeGamepadId: string | null = null;
let lastPollAt: number | null = null;
let secondarySubHeld = false;
function getConnectedGamepads(): GamepadLike[] {
return resolveConnectedGamepads(options.getGamepads());
@@ -290,6 +303,15 @@ export function createGamepadController(options: GamepadControllerOptions) {
});
}
// Level-triggered: reports every change in held state, so releasing the button,
// disconnecting the pad, disabling the controller, or opening a blocking modal
// all hide the secondary subtitle again instead of leaving it stuck visible.
function syncSecondarySubHold(held: boolean): void {
if (held === secondarySubHeld) return;
secondarySubHeld = held;
options.setSecondarySubHeld(held);
}
function handleActionEdge(
actionKey: string,
binding: ResolvedControllerDiscreteBinding,
@@ -355,6 +377,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
['previousAudio', config.bindings.previousAudio],
['nextAudio', config.bindings.nextAudio],
['playCurrentAudio', config.bindings.playCurrentAudio],
['previousEntry', config.bindings.previousEntry],
['nextEntry', config.bindings.nextEntry],
['toggleMpvPause', config.bindings.toggleMpvPause],
] as const;
@@ -398,6 +422,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
publishState(connectedGamepads, activeGamepad);
if (!activeGamepad) {
syncSecondarySubHold(false);
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
@@ -413,6 +438,18 @@ export function createGamepadController(options: GamepadControllerOptions) {
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
// Unlike most actions, this one does not require keyboard-only mode, so it can be
// used while simply watching.
syncSecondarySubHold(
activeConfig.enabled &&
!options.getInteractionBlocked() &&
resolveDiscreteBindingPressed(
activeGamepad,
activeConfig.bindings.holdSecondarySub,
activeConfig,
),
);
if (activeConfig.enabled) {
handleActionEdge(
'toggleKeyboardOnlyMode',
@@ -484,6 +521,20 @@ export function createGamepadController(options: GamepadControllerOptions) {
activeConfig,
options.playCurrentAudio,
);
handleActionEdge(
'previousEntry',
activeConfig.bindings.previousEntry,
activeGamepad,
activeConfig,
options.previousEntry,
);
handleActionEdge(
'nextEntry',
activeConfig.bindings.nextEntry,
activeGamepad,
activeConfig,
options.nextEntry,
);
const primaryScroll = resolveAxisBindingValue(
activeGamepad,
@@ -509,6 +560,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
} else {
resetHeldAction(jumpHold);
// Popup-only buttons keep tracking the pad while the popup is closed, like blocked
// actions do: a release is not missed, and a button held as the popup opens waits for
// a fresh press instead of firing at once.
for (const actionKey of POPUP_ONLY_ACTIONS) {
previousActions.set(
actionKey,
resolveDiscreteBindingPressed(
activeGamepad,
activeConfig.bindings[actionKey],
activeConfig,
),
);
}
}
handleActionEdge(
+29
View File
@@ -16,6 +16,7 @@ type CommandEventDetail = {
visible?: boolean;
key?: string;
code?: string;
modifiers?: string[];
repeat?: boolean;
direction?: number;
deltaX?: number;
@@ -2525,3 +2526,31 @@ test('an unfinished built-in y chord cannot start a configured sequence', async
testGlobals.restore();
}
});
test('controller popup entry navigation forwards Alt+Up / Alt+Down only while the popup is open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
assert.equal(handlers.movePopupEntryForController(1), false);
assert.equal(
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
false,
);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
assert.equal(handlers.movePopupEntryForController(1), true);
assert.equal(handlers.movePopupEntryForController(-1), true);
const forwarded = testGlobals.commandEvents
.filter((event) => event.type === 'forwardKeyDown')
.map((event) => [event.key, event.code, event.modifiers]);
assert.deepEqual(forwarded, [
['ArrowDown', 'ArrowDown', ['alt']],
['ArrowUp', 'ArrowUp', ['alt']],
]);
} finally {
testGlobals.restore();
}
});
+13
View File
@@ -828,6 +828,18 @@ export function createKeyboardHandlers(
return true;
}
// Sends the same Alt+Up / Alt+Down a keyboard user would press, which Yomitan's
// default hotkeys map to its previousEntry / nextEntry actions. Moving the entry
// also changes which entry "mine selected" mines.
function movePopupEntryForController(direction: -1 | 1): boolean {
if (!isLookupPopupOpen(ctx.state, document)) {
return false;
}
const key = direction < 0 ? 'ArrowUp' : 'ArrowDown';
dispatchYomitanPopupKeydown(key, key, ['alt'], false);
return true;
}
function playCurrentAudioForController(): boolean {
if (!isLookupPopupOpen(ctx.state, document)) {
return false;
@@ -1396,6 +1408,7 @@ export function createKeyboardHandlers(
mineSelectedFromController,
cyclePopupAudioSourceForController,
playCurrentAudioForController,
movePopupEntryForController,
scrollPopupByController,
};
}
@@ -16,6 +16,9 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -86,7 +89,10 @@ function findOne(root: FakeElement, className: string, text?: string): FakeEleme
}
/** Renders the form into a fake container with `document` stubbed for the duration. */
function renderForm(learningActionId: ActionId | null) {
function renderForm(
learningActionId: ActionId | null,
options: { bindings?: typeof BINDINGS; mapping?: string } = {},
) {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
@@ -98,7 +104,8 @@ function renderForm(learningActionId: ActionId | null) {
const container = new FakeElement();
createControllerConfigForm({
container: container as unknown as HTMLElement,
getBindings: () => BINDINGS,
getBindings: () => options.bindings ?? BINDINGS,
getControllerMapping: () => options.mapping,
getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
@@ -141,9 +148,12 @@ test('controller config form renders grouped rows with friendly binding labels',
[
['Toggle Lookup', 'A / Cross'],
['Close Lookup', 'B / Circle'],
['Previous Entry', 'None'],
['Next Entry', 'None'],
['Mine Card', 'X / Square'],
['Toggle Keyboard-Only Mode', 'Y / Triangle'],
['Toggle MPV Pause', 'R3 / RS'],
['Show Secondary Subtitle (Hold)', 'None'],
['Quit MPV', 'Back / Select'],
['Previous Audio', 'None'],
['Next Audio', 'RB / R1'],
@@ -160,7 +170,7 @@ test('controller config form renders grouped rows with friendly binding labels',
);
assert.deepEqual(
findAll(container, 'disabled').map((badge) => badge.textContent),
['None', 'None', 'None'],
['None', 'None', 'None', 'None', 'None', 'None'],
);
assert.deepEqual(findAll(container, 'expanded'), []);
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
@@ -227,3 +237,29 @@ for (const c of [
assert.deepEqual(calls, [c.expected]);
});
}
test('controller config form names buttons by the W3C standard layout for standard pads', () => {
const bindings: typeof BINDINGS = {
...BINDINGS,
quitMpv: { kind: 'button', buttonIndex: 6 },
toggleMpvPause: { kind: 'button', buttonIndex: 10 },
holdSecondarySub: { kind: 'button', buttonIndex: 11 },
previousEntry: { kind: 'button', buttonIndex: 8 },
nextEntry: { kind: 'button', buttonIndex: 9 },
rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
};
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
const standard = renderForm(null, { bindings, mapping: 'standard' });
assert.equal(badge(standard.row('Quit MPV')), 'LT / L2');
assert.equal(badge(standard.row('Previous Entry')), 'Back / Select');
assert.equal(badge(standard.row('Next Entry')), 'Start / Options');
assert.equal(badge(standard.row('Toggle MPV Pause')), 'L3 / LS');
assert.equal(badge(standard.row('Show Secondary Subtitle (Hold)')), 'R3 / RS');
assert.equal(badge(standard.row('Popup Jump (Stick)')), 'Right Stick Y');
// Pads without the standard mapping keep the previous names.
const raw = renderForm(null, { bindings, mapping: '' });
assert.equal(badge(raw.row('Quit MPV')), 'Back / Select');
assert.equal(badge(raw.row('Previous Entry')), 'L3 / LS');
});
+76 -13
View File
@@ -30,6 +30,20 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 1 },
},
{
id: 'previousEntry',
label: 'Previous Entry',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'nextEntry',
label: 'Next Entry',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'mineCard',
label: 'Mine Card',
@@ -51,6 +65,13 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 9 },
},
{
id: 'holdSecondarySub',
label: 'Show Secondary Subtitle (Hold)',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'quitMpv',
label: 'Quit MPV',
@@ -132,7 +153,9 @@ export function getDefaultDpadFallback(
return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none';
}
const STANDARD_BUTTON_NAMES: Record<number, string> = {
// Names for pads that do not report the W3C "standard" mapping. Raw layouts
// vary by driver, so these are a best guess.
const RAW_LAYOUT_BUTTON_NAMES: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
@@ -152,7 +175,7 @@ const STANDARD_BUTTON_NAMES: Record<number, string> = {
16: 'Guide / Home',
};
const STANDARD_AXIS_NAMES: Record<number, string> = {
const RAW_LAYOUT_AXIS_NAMES: Record<number, string> = {
0: 'Left Stick X',
1: 'Left Stick Y',
2: 'Left Trigger',
@@ -161,18 +184,52 @@ const STANDARD_AXIS_NAMES: Record<number, string> = {
5: 'Right Trigger',
};
// Names for pads that report `mapping === 'standard'` (most Xbox, PlayStation,
// Switch and 8BitDo pads). Index meanings are fixed by the W3C Gamepad spec:
// https://w3c.github.io/gamepad/#remapping
const W3C_STANDARD_BUTTON_NAMES: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
3: 'Y / Triangle',
4: 'LB / L1',
5: 'RB / R1',
6: 'LT / L2',
7: 'RT / R2',
8: 'Back / Select',
9: 'Start / Options',
10: 'L3 / LS',
11: 'R3 / RS',
12: 'D-pad Up',
13: 'D-pad Down',
14: 'D-pad Left',
15: 'D-pad Right',
16: 'Guide / Home',
};
const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
0: 'Left Stick X',
1: 'Left Stick Y',
2: 'Right Stick X',
3: 'Right Stick Y',
};
type ControllerMapping = string | null | undefined;
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
none: 'None',
horizontal: 'D-pad \u2194',
vertical: 'D-pad \u2195',
};
function getFriendlyButtonName(buttonIndex: number): string {
return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`;
export function getFriendlyButtonName(buttonIndex: number, mapping?: ControllerMapping): string {
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
return names[buttonIndex] ?? `Button ${buttonIndex}`;
}
function getFriendlyAxisName(axisIndex: number): string {
return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`;
export function getFriendlyAxisName(axisIndex: number, mapping?: ControllerMapping): string {
const names = mapping === 'standard' ? W3C_STANDARD_AXIS_NAMES : RAW_LAYOUT_AXIS_NAMES;
return names[axisIndex] ?? `Axis ${axisIndex}`;
}
export function formatControllerBindingSummary(
@@ -193,21 +250,25 @@ export function formatControllerBindingSummary(
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
}
function formatFriendlyStickLabel(binding: ResolvedControllerAxisBinding): string {
function formatFriendlyStickLabel(
binding: ResolvedControllerAxisBinding,
mapping: ControllerMapping,
): string {
if (binding.kind === 'none') return 'None';
return getFriendlyAxisName(binding.axisIndex);
return getFriendlyAxisName(binding.axisIndex, mapping);
}
function formatFriendlyBindingLabel(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
mapping: ControllerMapping,
): string {
if (binding.kind === 'none') return 'None';
if ('direction' in binding) {
const name = getFriendlyAxisName(binding.axisIndex);
const name = getFriendlyAxisName(binding.axisIndex, mapping);
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
}
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex);
return getFriendlyAxisName(binding.axisIndex);
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex, mapping);
return getFriendlyAxisName(binding.axisIndex, mapping);
}
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
@@ -224,6 +285,8 @@ export function createControllerConfigForm(options: {
onDpadLearn: (actionId: ControllerBindingActionId) => void;
onDpadClear: (actionId: ControllerBindingActionId) => void;
onDpadReset: (actionId: ControllerBindingActionId) => void;
/** Gamepad API `mapping` of the controller being configured; picks the button names. */
getControllerMapping?: () => ControllerMapping;
}) {
let expandedRowKey: ExpandedRowKey | null = null;
@@ -275,7 +338,7 @@ export function createControllerConfigForm(options: {
const row = createRow(
definition.label,
formatFriendlyBindingLabel(binding),
formatFriendlyBindingLabel(binding, options.getControllerMapping?.()),
binding.kind === 'none',
isExpanded,
`Learn ${definition.label}`,
@@ -329,7 +392,7 @@ export function createControllerConfigForm(options: {
const row = createRow(
`${definition.label} (Stick)`,
formatFriendlyStickLabel(binding),
formatFriendlyStickLabel(binding, options.getControllerMapping?.()),
binding.kind === 'none',
isExpanded,
`Learn ${definition.label} stick`,
@@ -71,6 +71,9 @@ test('controller debug modal renders active controller axes, buttons, and config
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -186,6 +189,9 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -300,6 +306,9 @@ test('controller debug modal stays closed and notifies when controller support i
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -153,6 +153,9 @@ function buildContext() {
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+4
View File
@@ -56,6 +56,7 @@ export function createControllerSelectModal(
const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList,
getControllerMapping: () => getSelectedController()?.mapping ?? null,
getBindings: () =>
getSelectedControllerConfig()?.bindings ?? {
toggleLookup: { kind: 'button', buttonIndex: 0 },
@@ -67,6 +68,9 @@ export function createControllerSelectModal(
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+13
View File
@@ -461,6 +461,9 @@ function startControllerPolling(): void {
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -494,9 +497,19 @@ function startControllerPolling(): void {
playCurrentAudio: () => {
keyboardHandlers.playCurrentAudioForController();
},
previousEntry: () => {
keyboardHandlers.movePopupEntryForController(-1);
},
nextEntry: () => {
keyboardHandlers.movePopupEntryForController(1);
},
toggleMpvPause: () => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
},
setSecondarySubHeld: (held) => {
subtitleRenderer.setSecondarySubHeld(held);
measurementReporter.schedule();
},
scrollPopup: (deltaPixels) => {
emitControllerPopupScroll(deltaPixels);
},
+26
View File
@@ -60,6 +60,12 @@ class FakeElement {
dataset: Record<string, string> = {};
style = new FakeStyleDeclaration();
className = '';
classes = new Set<string>();
classList = {
add: (...names: string[]) => names.forEach((name) => this.classes.add(name)),
remove: (...names: string[]) => names.forEach((name) => this.classes.delete(name)),
contains: (name: string) => this.classes.has(name),
};
replaceChildrenCalls = 0;
src?: string;
alt?: string;
@@ -726,3 +732,23 @@ test('prepareSecondarySubtitleLines strips ASS override tags and handles empty i
assert.deepEqual(prepareSecondarySubtitleLines(''), []);
assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []);
});
test('setSecondarySubHeld shows the secondary subtitle while held and restores the chosen mode', () => {
withRenderer({}, ({ renderer, dom }) => {
const modeClasses = () =>
[...dom.secondarySubContainer.classes].filter((name) => name.startsWith('secondary-sub-'));
renderer.updateSecondarySubMode('hidden');
assert.deepEqual(modeClasses(), ['secondary-sub-hidden']);
renderer.setSecondarySubHeld(true);
assert.deepEqual(modeClasses(), ['secondary-sub-visible']);
// A mode change from mpv or the CLI while held is remembered, not shown yet.
renderer.updateSecondarySubMode('hover');
assert.deepEqual(modeClasses(), ['secondary-sub-visible']);
renderer.setSecondarySubHeld(false);
assert.deepEqual(modeClasses(), ['secondary-sub-hover']);
});
});
+21 -2
View File
@@ -830,13 +830,31 @@ export function createSubtitleRenderer(ctx: RendererContext) {
}
}
function updateSecondarySubMode(mode: SecondarySubMode): void {
// The mode chosen by the user (hidden / visible / hover). A controller hold
// temporarily shows it as `visible` without changing this, so releasing the
// button restores exactly what was there before.
let secondarySubBaseMode: SecondarySubMode = 'hidden';
let secondarySubHeld = false;
function applySecondarySubModeClass(): void {
const effectiveMode: SecondarySubMode = secondarySubHeld ? 'visible' : secondarySubBaseMode;
ctx.dom.secondarySubContainer.classList.remove(
'secondary-sub-hidden',
'secondary-sub-visible',
'secondary-sub-hover',
);
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`);
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${effectiveMode}`);
}
function updateSecondarySubMode(mode: SecondarySubMode): void {
secondarySubBaseMode = mode;
applySecondarySubModeClass();
}
function setSecondarySubHeld(held: boolean): void {
if (secondarySubHeld === held) return;
secondarySubHeld = held;
applySecondarySubModeClass();
}
function updatePrimarySubMode(mode: PrimarySubMode): void {
@@ -1071,5 +1089,6 @@ export function createSubtitleRenderer(ctx: RendererContext) {
renderSubtitle,
updatePrimarySubMode,
updateSecondarySubMode,
setSecondarySubHeld,
};
}
+3
View File
@@ -217,6 +217,9 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
'holdSecondarySub',
'previousEntry',
'nextEntry',
] as const;
for (const key of discreteKeys) {
if (value[key] === undefined) continue;
+6
View File
@@ -234,6 +234,9 @@ export interface ControllerBindingsConfig {
nextAudio?: ControllerDiscreteBindingConfig;
playCurrentAudio?: ControllerDiscreteBindingConfig;
toggleMpvPause?: ControllerDiscreteBindingConfig;
holdSecondarySub?: ControllerDiscreteBindingConfig;
previousEntry?: ControllerDiscreteBindingConfig;
nextEntry?: ControllerDiscreteBindingConfig;
leftStickHorizontal?: ControllerAxisBindingConfig;
leftStickVertical?: ControllerAxisBindingConfig;
rightStickHorizontal?: ControllerAxisBindingConfig;
@@ -250,6 +253,9 @@ export interface ResolvedControllerBindingsConfig {
nextAudio?: ResolvedControllerDiscreteBinding;
playCurrentAudio?: ResolvedControllerDiscreteBinding;
toggleMpvPause?: ResolvedControllerDiscreteBinding;
holdSecondarySub?: ResolvedControllerDiscreteBinding;
previousEntry?: ResolvedControllerDiscreteBinding;
nextEntry?: ResolvedControllerDiscreteBinding;
leftStickHorizontal?: ResolvedControllerAxisBinding;
leftStickVertical?: ResolvedControllerAxisBinding;
rightStickHorizontal?: ResolvedControllerAxisBinding;