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

@@ -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,
};
}