From 4f6006f56580f1b17a021a4c9ee1415e4ce246ad Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 14 Mar 2026 20:39:37 -0700 Subject: [PATCH] fix: address controller remap review feedback --- config.example.jsonc | 40 +++++----- docs-site/configuration.md | 17 +++++ docs-site/public/config.example.jsonc | 40 +++++----- src/config/config.test.ts | 2 +- src/config/definitions/options-core.ts | 25 +++++- src/config/resolve/core-domains.ts | 2 +- src/main.ts | 4 +- src/main/controller-config-update.test.ts | 54 +++++++++++++ src/main/controller-config-update.ts | 38 ++++++++++ .../controller-binding-capture.test.ts | 31 ++++++++ .../handlers/controller-binding-capture.ts | 22 ++---- .../handlers/gamepad-controller.test.ts | 76 +++++++++++++++++++ .../modals/controller-config-form.test.ts | 16 ++-- src/renderer/modals/controller-select.ts | 11 ++- 14 files changed, 306 insertions(+), 72 deletions(-) create mode 100644 src/main/controller-config-update.test.ts create mode 100644 src/main/controller-config-update.ts diff --git a/config.example.jsonc b/config.example.jsonc index 19c542ab..ec1000d4 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 2341f67d..6a4de49c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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 diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 19c542ab..ec1000d4 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. // ========================================== diff --git a/src/config/config.test.ts b/src/config/config.test.ts index f22f2f39..e559de60 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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"/); diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 652526b9..8f50a7f5 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -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) => [ diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index d39a8a07..feac20c4 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -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'.", ); } } diff --git a/src/main.ts b/src/main.ts index 1a5c18d2..c8a25f19 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 }) => { diff --git a/src/main/controller-config-update.test.ts b/src/main/controller-config-update.test.ts new file mode 100644 index 00000000..73d0ab43 --- /dev/null +++ b/src/main/controller-config-update.test.ts @@ -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' }); +}); diff --git a/src/main/controller-config-update.ts b/src/main/controller-config-update.ts new file mode 100644 index 00000000..de58a89d --- /dev/null +++ b/src/main/controller-config-update.ts @@ -0,0 +1,38 @@ +import type { ControllerConfigUpdate, RawConfig } from '../types'; + +type RawControllerConfig = NonNullable; +type RawControllerBindings = NonNullable; + +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)[key] = JSON.parse(JSON.stringify(value)); + } + + nextController.bindings = nextBindings; + } + + return nextController; +} diff --git a/src/renderer/handlers/controller-binding-capture.test.ts b/src/renderer/handlers/controller-binding-capture.test.ts index a6e6ae53..05802a90 100644 --- a/src/renderer/handlers/controller-binding-capture.test.ts +++ b/src/renderer/handlers/controller-binding-capture.test.ts @@ -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' }, + }); +}); diff --git a/src/renderer/handlers/controller-binding-capture.ts b/src/renderer/handlers/controller-binding-capture.ts index cc2ff3d2..0a691590 100644 --- a/src/renderer/handlers/controller-binding-capture.ts +++ b/src/renderer/handlers/controller-binding-capture.ts @@ -112,23 +112,13 @@ 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' - ? { - actionId: target.actionId, - bindingType: 'discrete', - binding: { kind: 'button', buttonIndex: index }, - } - : { - actionId: target.actionId, - bindingType: 'axis', - binding: { - kind: 'axis', - axisIndex: index, - dpadFallback: target.dpadFallback, - }, - }; + const result: ControllerBindingCaptureResult = { + actionId: target.actionId, + bindingType: 'discrete', + binding: { kind: 'button', buttonIndex: index }, + }; cancel(); return result; } diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index b1433f7e..abb6f1a2 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -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 })); diff --git a/src/renderer/modals/controller-config-form.test.ts b/src/renderer/modals/controller-config-form.test.ts index 419ce480..e99d16e7 100644 --- a/src/renderer/modals/controller-config-form.test.ts +++ b/src/renderer/modals/controller-config-form.test.ts @@ -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'); + } } }); diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index 135c6fd8..5f43b143 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -29,7 +29,10 @@ export function createControllerSelectModal( let lastRenderedDevicesKey = ''; let lastRenderedActiveGamepadId: string | null = null; let lastRenderedPreferredId = ''; - let learningActionId: keyof NonNullable['bindings'] | null = null; + type ControllerBindingKey = keyof NonNullable['bindings']; + type ControllerBindingValue = + NonNullable['bindings']>[ControllerBindingKey]; + let learningActionId: ControllerBindingKey | null = null; let bindingCapture: ReturnType | null = null; const controllerConfigForm = createControllerConfigForm({ @@ -178,8 +181,8 @@ export function createControllerSelectModal( } async function saveBinding( - actionId: keyof NonNullable['bindings'], - binding: NonNullable['bindings']>[typeof actionId], + actionId: ControllerBindingKey, + binding: ControllerBindingValue, ): Promise { 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['bindings'], result.binding as never); + void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue); } }