mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
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:
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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user