fix: address controller remap review feedback

This commit is contained in:
2026-03-14 20:39:37 -07:00
parent ccdee0c62c
commit 4f6006f565
14 changed files with 306 additions and 72 deletions

View File

@@ -81,43 +81,43 @@
"rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": {
"toggleLookup": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"closeLookup": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"mineCard": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"quitMpv": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousAudio": {
"kind": "none" // Discrete binding input source kind. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually.
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextAudio": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"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.
}, // 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.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
@@ -138,7 +138,7 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Bindings setting.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================

View File

@@ -517,6 +517,7 @@ Important behavior:
- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -584,10 +585,26 @@ Default logical mapping:
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
If you bind a discrete action to an axis manually, include `direction`:
```jsonc
{
"controller": {
"bindings": {
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" }
}
}
}
```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts

View File

@@ -81,43 +81,43 @@
"rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": {
"toggleLookup": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"closeLookup": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"mineCard": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"quitMpv": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousAudio": {
"kind": "none" // Discrete binding input source kind. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually.
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextAudio": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. Values: none | button | axis
"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.
}, // 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.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
@@ -138,7 +138,7 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Bindings setting.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================

View File

@@ -1926,7 +1926,7 @@ test('template generator includes known keys', () => {
);
assert.match(
output,
/"kind": "button",? \/\/ Discrete binding input source kind\. Values: none \| button \| axis/,
/"kind": "button",? \/\/ Discrete binding input source kind\. When kind is "axis", set both axisIndex and direction\. Values: none \| button \| axis/,
);
assert.match(output, /"toggleLookup": \{\s*"kind": "button"/);
assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/);

View File

@@ -152,6 +152,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.repeatIntervalMs,
description: 'Repeat interval for held controller actions.',
},
{
path: 'controller.buttonIndices',
kind: 'object',
defaultValue: defaultConfig.controller.buttonIndices,
description:
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.',
},
{
path: 'controller.buttonIndices.select',
kind: 'number',
@@ -218,19 +225,27 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
description: 'Raw button index used for controller R2 input.',
},
{
path: 'controller.bindings',
kind: 'object',
defaultValue: defaultConfig.controller.bindings,
description:
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
},
...discreteBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
kind: 'object' as const,
defaultValue: binding.defaultValue,
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually.`,
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
},
{
path: `controller.bindings.${binding.id}.kind`,
kind: 'enum' as const,
enumValues: ['none', 'button', 'axis'],
defaultValue: binding.defaultValue.kind,
description: 'Discrete binding input source kind.',
description:
'Discrete binding input source kind. When kind is "axis", set both axisIndex and direction.',
},
{
path: `controller.bindings.${binding.id}.buttonIndex`,
@@ -249,8 +264,10 @@ export function buildCoreConfigOptionRegistry(
path: `controller.bindings.${binding.id}.direction`,
kind: 'enum' as const,
enumValues: ['negative', 'positive'],
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined,
description: 'Axis direction captured for this discrete controller action.',
defaultValue:
binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined,
description:
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
},
]),
...axisBindings.flatMap((binding) => [

View File

@@ -421,7 +421,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller axis name or binding object with kind 'axis'.",
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}

View File

@@ -30,6 +30,7 @@ import {
dialog,
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
@@ -3457,8 +3458,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getControllerConfig: () => getResolvedConfig().controller,
saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
configService.patchRawConfig({
controller: update,
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
});
},
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyControllerConfigUpdate } from './controller-config-update.js';
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
const next = applyControllerConfigUpdate(
{
preferredGamepadId: 'pad-1',
bindings: {
toggleLookup: { kind: 'axis', axisIndex: 4, direction: 'positive' },
closeLookup: { kind: 'button', buttonIndex: 1 },
},
},
{
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
);
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 11 });
assert.deepEqual(next.bindings?.closeLookup, { kind: 'button', buttonIndex: 1 });
});
test('applyControllerConfigUpdate merges buttonIndices while replacing only updated binding leaves', () => {
const next = applyControllerConfigUpdate(
{
buttonIndices: {
select: 6,
buttonSouth: 0,
},
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
},
},
{
buttonIndices: {
buttonSouth: 9,
},
bindings: {
closeLookup: { kind: 'none' },
},
},
);
assert.deepEqual(next.buttonIndices, {
select: 6,
buttonSouth: 9,
});
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
});

View File

@@ -0,0 +1,38 @@
import type { ControllerConfigUpdate, RawConfig } from '../types';
type RawControllerConfig = NonNullable<RawConfig['controller']>;
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
export function applyControllerConfigUpdate(
currentController: RawConfig['controller'] | undefined,
update: ControllerConfigUpdate,
): RawControllerConfig {
const nextController: RawControllerConfig = {
...(currentController ?? {}),
...update,
};
if (currentController?.buttonIndices || update.buttonIndices) {
nextController.buttonIndices = {
...(currentController?.buttonIndices ?? {}),
...(update.buttonIndices ?? {}),
};
}
if (currentController?.bindings || update.bindings) {
const nextBindings: RawControllerBindings = {
...(currentController?.bindings ?? {}),
};
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
>) {
if (value === undefined) continue;
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value));
}
nextController.bindings = nextBindings;
}
return nextController;
}

View File

@@ -96,3 +96,34 @@ test('controller binding capture emits axis binding for continuous learn mode',
binding: { kind: 'axis', axisIndex: 3, dpadFallback: 'horizontal' },
});
});
test('controller binding capture ignores button presses for continuous learn mode', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.2,
});
capture.arm(
{
actionId: 'leftStickHorizontal',
bindingType: 'axis',
dpadFallback: 'horizontal',
},
createSnapshot(),
);
assert.equal(
capture.poll(
createSnapshot({
buttons: [{ value: 1, pressed: true, touched: true }],
}),
),
null,
);
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0.75, 0, 0] })), {
actionId: 'leftStickHorizontal',
bindingType: 'axis',
binding: { kind: 'axis', axisIndex: 2, dpadFallback: 'horizontal' },
});
});

View File

@@ -112,22 +112,12 @@ export function createControllerBindingCapture(options: {
for (let index = 0; index < snapshot.buttons.length; index += 1) {
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
if (blockedButtons.has(index)) continue;
if (target.bindingType === 'axis') continue;
const result: ControllerBindingCaptureResult =
target.bindingType === 'discrete'
? {
const result: ControllerBindingCaptureResult = {
actionId: target.actionId,
bindingType: 'discrete',
binding: { kind: 'button', buttonIndex: index },
}
: {
actionId: target.actionId,
bindingType: 'axis',
binding: {
kind: 'axis',
axisIndex: index,
dpadFallback: target.dpadFallback,
},
};
cancel();
return result;

View File

@@ -257,6 +257,82 @@ test('gamepad controller allows keyboard-mode toggle while other actions stay ga
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller re-evaluates interaction gating after toggling keyboard mode', () => {
const calls: string[] = [];
let keyboardModeEnabled = true;
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => keyboardModeEnabled,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {
calls.push('toggle-keyboard-mode');
keyboardModeEnabled = false;
},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller resets edge state when active controller changes', () => {
const calls: string[] = [];
let currentGamepads = [
createGamepad('pad-1', {
buttons: [{ value: 1, pressed: true, touched: true }],
}),
];
const controller = createGamepadController({
getGamepads: () => currentGamepads,
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
currentGamepads = [
createGamepad('pad-2', {
buttons: [{ value: 1, pressed: true, touched: true }],
}),
];
controller.poll(50);
assert.deepEqual(calls, ['toggle-lookup', 'toggle-lookup']);
});
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));

View File

@@ -52,8 +52,7 @@ function createFakeElement() {
}
test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
const globals = globalThis as typeof globalThis & { document?: unknown };
const previousDocument = globals.document;
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
@@ -83,7 +82,7 @@ test('controller config form renders rows and dispatches learn clear reset callb
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}) as never,
getLearningActionId: () => 'toggleLookup',
onLearn: (actionId) => calls.push(`learn:${actionId}`),
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
onClear: (actionId) => calls.push(`clear:${actionId}`),
onReset: (actionId) => calls.push(`reset:${actionId}`),
});
@@ -97,13 +96,20 @@ test('controller config form renders rows and dispatches learn clear reset callb
firstRow.children[2].children[0].dispatch('click');
firstRow.children[2].children[1].dispatch('click');
firstRow.children[2].children[2].dispatch('click');
const firstAxisRow = container.children[13];
firstAxisRow.children[2].children[0].dispatch('click');
assert.deepEqual(calls, [
'learn:toggleLookup',
'learn:toggleLookup:discrete',
'clear:toggleLookup',
'reset:toggleLookup',
'learn:leftStickHorizontal:axis',
]);
} finally {
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
});

View File

@@ -29,7 +29,10 @@ export function createControllerSelectModal(
let lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = '';
let learningActionId: keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'] | null = null;
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
type ControllerBindingValue =
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
let learningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
const controllerConfigForm = createControllerConfigForm({
@@ -178,8 +181,8 @@ export function createControllerSelectModal(
}
async function saveBinding(
actionId: keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'],
binding: NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[typeof actionId],
actionId: ControllerBindingKey,
binding: ControllerBindingValue,
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
try {
@@ -242,7 +245,7 @@ export function createControllerSelectModal(
buttons: ctx.state.controllerRawButtons,
});
if (result) {
void saveBinding(result.actionId as keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'], result.binding as never);
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
}
}