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. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 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": { "bindings": {
"toggleLookup": { "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. "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": { "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. "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": { "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. "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": { "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. "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": { "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. "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": { "previousAudio": {
"kind": "none" // Discrete binding input source kind. Values: none | button | axis "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. }, // 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": { "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. "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": { "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. "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": { "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. "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": { "leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action. "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. "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 "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. } // 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. }, // 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. - `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. - 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. - `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. - 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. - 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. 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 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 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. 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 ### Manual Card Update Shortcuts

View File

@@ -81,43 +81,43 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 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": { "bindings": {
"toggleLookup": { "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. "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": { "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. "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": { "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. "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": { "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. "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": { "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. "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": { "previousAudio": {
"kind": "none" // Discrete binding input source kind. Values: none | button | axis "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. }, // 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": { "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. "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": { "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. "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": { "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. "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": { "leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action. "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. "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 "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. } // 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. }, // 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( assert.match(
output, 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, /"toggleLookup": \{\s*"kind": "button"/);
assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/); assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/);

View File

@@ -152,6 +152,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.repeatIntervalMs, defaultValue: defaultConfig.controller.repeatIntervalMs,
description: 'Repeat interval for held controller actions.', 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', path: 'controller.buttonIndices.select',
kind: 'number', kind: 'number',
@@ -218,19 +225,27 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger, defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
description: 'Raw button index used for controller R2 input.', 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) => [ ...discreteBindings.flatMap((binding) => [
{ {
path: `controller.bindings.${binding.id}`, path: `controller.bindings.${binding.id}`,
kind: 'object' as const, kind: 'object' as const,
defaultValue: binding.defaultValue, 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`, path: `controller.bindings.${binding.id}.kind`,
kind: 'enum' as const, kind: 'enum' as const,
enumValues: ['none', 'button', 'axis'], enumValues: ['none', 'button', 'axis'],
defaultValue: binding.defaultValue.kind, 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`, path: `controller.bindings.${binding.id}.buttonIndex`,
@@ -249,8 +264,10 @@ export function buildCoreConfigOptionRegistry(
path: `controller.bindings.${binding.id}.direction`, path: `controller.bindings.${binding.id}.direction`,
kind: 'enum' as const, kind: 'enum' as const,
enumValues: ['negative', 'positive'], enumValues: ['negative', 'positive'],
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined, defaultValue:
description: 'Axis direction captured for this discrete controller action.', 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) => [ ...axisBindings.flatMap((binding) => [

View File

@@ -421,7 +421,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
`controller.bindings.${key}`, `controller.bindings.${key}`,
bindingValue, bindingValue,
resolved.controller.bindings[key], 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, dialog,
screen, screen,
} from 'electron'; } from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
function getPasswordStoreArg(argv: string[]): string | null { function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
@@ -3457,8 +3458,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
getControllerConfig: () => getResolvedConfig().controller, getControllerConfig: () => getResolvedConfig().controller,
saveControllerConfig: (update) => { saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
configService.patchRawConfig({ configService.patchRawConfig({
controller: update, controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
}); });
}, },
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { 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' }, 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,23 +112,13 @@ export function createControllerBindingCapture(options: {
for (let index = 0; index < snapshot.buttons.length; index += 1) { for (let index = 0; index < snapshot.buttons.length; index += 1) {
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue; if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
if (blockedButtons.has(index)) continue; if (blockedButtons.has(index)) continue;
if (target.bindingType === 'axis') continue;
const result: ControllerBindingCaptureResult = const result: ControllerBindingCaptureResult = {
target.bindingType === 'discrete' actionId: target.actionId,
? { bindingType: 'discrete',
actionId: target.actionId, binding: { kind: 'button', buttonIndex: index },
bindingType: 'discrete', };
binding: { kind: 'button', buttonIndex: index },
}
: {
actionId: target.actionId,
bindingType: 'axis',
binding: {
kind: 'axis',
axisIndex: index,
dpadFallback: target.dpadFallback,
},
};
cancel(); cancel();
return result; 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']); 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', () => { test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = []; const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false })); 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', () => { test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
const globals = globalThis as typeof globalThis & { document?: unknown }; const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', {
configurable: true, configurable: true,
value: { value: {
@@ -83,7 +82,7 @@ test('controller config form renders rows and dispatches learn clear reset callb
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}) as never, }) as never,
getLearningActionId: () => 'toggleLookup', getLearningActionId: () => 'toggleLookup',
onLearn: (actionId) => calls.push(`learn:${actionId}`), onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
onClear: (actionId) => calls.push(`clear:${actionId}`), onClear: (actionId) => calls.push(`clear:${actionId}`),
onReset: (actionId) => calls.push(`reset:${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[0].dispatch('click');
firstRow.children[2].children[1].dispatch('click'); firstRow.children[2].children[1].dispatch('click');
firstRow.children[2].children[2].dispatch('click'); firstRow.children[2].children[2].dispatch('click');
const firstAxisRow = container.children[13];
firstAxisRow.children[2].children[0].dispatch('click');
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'learn:toggleLookup', 'learn:toggleLookup:discrete',
'clear:toggleLookup', 'clear:toggleLookup',
'reset:toggleLookup', 'reset:toggleLookup',
'learn:leftStickHorizontal:axis',
]); ]);
} finally { } 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 lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null; let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = ''; 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; let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
const controllerConfigForm = createControllerConfigForm({ const controllerConfigForm = createControllerConfigForm({
@@ -178,8 +181,8 @@ export function createControllerSelectModal(
} }
async function saveBinding( async function saveBinding(
actionId: keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'], actionId: ControllerBindingKey,
binding: NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[typeof actionId], binding: ControllerBindingValue,
): Promise<void> { ): Promise<void> {
const definition = getControllerBindingDefinition(actionId); const definition = getControllerBindingDefinition(actionId);
try { try {
@@ -242,7 +245,7 @@ export function createControllerSelectModal(
buttons: ctx.state.controllerRawButtons, buttons: ctx.state.controllerRawButtons,
}); });
if (result) { 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);
} }
} }