From 478869ff2831fbfc58c104fc0fe9cfec7d66bf53 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 15 Mar 2026 15:55:45 -0700 Subject: [PATCH] feat(controller): add inline remap modal with descriptor-based bindings (#21) --- .gitignore | 1 + ...tion-easier-with-inline-remapping-modal.md | 94 ++ changes/controller-inline-remap.md | 4 + config.example.jsonc | 76 +- docs-site/changelog.md | 2 +- docs-site/configuration.md | 48 +- docs-site/public/config.example.jsonc | 76 +- docs-site/shortcuts.md | 4 +- docs-site/usage.md | 17 +- src/config/config.test.ts | 121 ++- src/config/definitions/defaults-core.ts | 26 +- src/config/definitions/options-core.ts | 254 ++--- src/config/definitions/template-sections.ts | 2 +- src/config/resolve/core-domains.ts | 205 ++++- src/core/services/ipc.test.ts | 340 +++---- src/core/services/ipc.ts | 13 + src/main.ts | 7 + src/main/controller-config-update.test.ts | 54 ++ src/main/controller-config-update.ts | 38 + src/main/dependencies.ts | 2 + .../composers/ipc-runtime-composer.test.ts | 1 + src/preload.ts | 3 + .../controller-binding-capture.test.ts | 129 +++ .../handlers/controller-binding-capture.ts | 194 ++++ .../handlers/gamepad-controller.test.ts | 235 ++++- src/renderer/handlers/gamepad-controller.ts | 365 ++++---- src/renderer/index.html | 12 +- .../modals/controller-config-form.test.ts | 146 +++ src/renderer/modals/controller-config-form.ts | 429 +++++++++ src/renderer/modals/controller-debug.test.ts | 52 +- src/renderer/modals/controller-select.test.ts | 870 +++++------------- src/renderer/modals/controller-select.ts | 331 +++++-- src/renderer/renderer.ts | 26 +- src/renderer/style.css | 191 ++++ src/renderer/utils/dom.ts | 10 +- src/shared/ipc/contracts.ts | 1 + src/shared/ipc/validators.ts | 94 ++ src/types.ts | 94 +- 38 files changed, 3136 insertions(+), 1431 deletions(-) create mode 100644 backlog/tasks/task-165 - Make-controller-configuration-easier-with-inline-remapping-modal.md create mode 100644 changes/controller-inline-remap.md create mode 100644 src/main/controller-config-update.test.ts create mode 100644 src/main/controller-config-update.ts create mode 100644 src/renderer/handlers/controller-binding-capture.test.ts create mode 100644 src/renderer/handlers/controller-binding-capture.ts create mode 100644 src/renderer/modals/controller-config-form.test.ts create mode 100644 src/renderer/modals/controller-config-form.ts diff --git a/.gitignore b/.gitignore index a0e4f8b..ef9bd4b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ tests/* .agents/skills/subminer-scrum-master/* !.agents/skills/subminer-scrum-master/SKILL.md favicon.png +.claude/* diff --git a/backlog/tasks/task-165 - Make-controller-configuration-easier-with-inline-remapping-modal.md b/backlog/tasks/task-165 - Make-controller-configuration-easier-with-inline-remapping-modal.md new file mode 100644 index 0000000..a215d3b --- /dev/null +++ b/backlog/tasks/task-165 - Make-controller-configuration-easier-with-inline-remapping-modal.md @@ -0,0 +1,94 @@ +--- +id: TASK-165 +title: Make controller configuration easier with inline remapping modal +status: To Do +assignee: + - Codex +created_date: '2026-03-13 00:10' +updated_date: '2026-03-13 00:10' +labels: + - enhancement + - renderer + - overlay + - input + - config +dependencies: + - TASK-159 +references: + - src/renderer/modals/controller-select.ts + - src/renderer/modals/controller-debug.ts + - src/renderer/handlers/gamepad-controller.ts + - src/renderer/index.html + - src/renderer/style.css + - src/renderer/utils/dom.ts + - src/preload.ts + - src/core/services/ipc.ts + - src/main.ts + - src/types.ts + - src/config/definitions/defaults-core.ts + - src/config/definitions/options-core.ts + - config.example.jsonc + - docs/plans/2026-03-13-overlay-controller-config-remap-design.md + - docs/plans/2026-03-13-overlay-controller-config-remap.md +--- + +## Description + + +Replace the current controller-selection-only modal with a denser controller configuration surface that keeps device selection and adds inline controller remapping. The new flow should feel like emulator configuration: pick an overlay action, arm capture, then press the matching controller button, trigger, d-pad direction, or stick direction to bind it. Keep the current overlay-local renderer architecture, preserve controller gating to keyboard-only mode, and retain the separate raw debug modal for troubleshooting. + + +## Acceptance Criteria + + +- [ ] #1 `Alt+C` opens a controller modal that includes both preferred-controller selection and controller-config editing in one surface. +- [ ] #2 Controller device selection uses a compact dropdown or equivalent compact picker instead of the current full-height device list. +- [ ] #3 Each remappable controller action shows its current binding and supports learn/capture, clear, and reset-to-default flows. +- [ ] #4 Learn mode captures the next fresh controller input edge or stick/d-pad direction, not a held/stale input. +- [ ] #5 Captured bindings can represent non-standard controllers without depending only on the browser's standard semantic button names. +- [ ] #6 Updated bindings persist through the existing config pipeline and take effect in the renderer without restart unless a field explicitly requires reopen/reload. +- [ ] #7 Existing controller behavior remains gated to keyboard-only mode except for the controller action that toggles keyboard-only mode itself. +- [ ] #8 Renderer/config/IPC regression tests cover the new modal layout, capture flow, persistence, and runtime mapping behavior. +- [ ] #9 Docs/config example explain the new controller-config flow and when to use the debug modal. + + +## Implementation Plan + + +1. Add the design doc and implementation plan for inline controller remapping, tied to a new backlog task instead of reopening the already-completed base controller-support task. +2. Expand controller config types/defaults/template output so action bindings can store captured input descriptors, not only semantic button-name enums. +3. Extend preload/main/IPC write paths from preferred-controller-only saves to full controller-config patching needed by the modal. +4. Redesign the controller modal UI into a compact device picker plus action-binding editor with learn, clear, and reset affordances. +5. Add renderer capture state and a learn-mode runtime that waits for neutral-to-active transitions before saving a binding. +6. Update the gamepad runtime to resolve the new stored descriptors into actions while preserving current gating and repeat/deadzone behavior. +7. Keep the raw debug modal as a separate advanced surface; optionally expose copyable input-descriptor text for troubleshooting. +8. Add focused regression tests first, then run the maintained gate needed for docs/config/renderer/main changes. + + +## Implementation Notes + + +Planning only in this pass. + +Current-state findings: + +- `src/renderer/modals/controller-select.ts` only persists `preferredGamepadId` / `preferredGamepadLabel`. +- `src/preload.ts`, `src/core/services/ipc.ts`, and `src/main.ts` only expose a narrow save path for preferred controller, not general controller config writes. +- `src/renderer/handlers/gamepad-controller.ts` currently resolves actions from semantic button bindings plus a few axis slots; this is fine for defaults but too narrow for emulator-style learn mode on non-standard controllers. +- `src/renderer/modals/controller-debug.ts` already provides the raw input surface needed for troubleshooting and for validating capture behavior. + +Recommended direction: + +- keep `Alt+C` as the single controller-config entrypoint +- keep `Alt+Shift+C` as raw debug +- introduce stored input descriptors for discrete bindings so learn mode can capture buttons, triggers, d-pad directions, and stick directions directly +- defer per-controller profiles; keep one global binding set plus preferred-controller selection for this pass + + +## Final Summary + + +Planned follow-up work to make controller configuration materially easier than the current “pick preferred device” modal. The proposed change keeps existing controller runtime/debug foundations, but upgrades the selection modal into a compact controller-config surface with inline learn-mode remapping and persistent binding storage. + +Main architectural change in scope: move from semantic-button-only binding storage toward captured input descriptors so the UI can reliably learn from buttons, triggers, d-pad directions, and stick directions on non-standard controllers. + diff --git a/changes/controller-inline-remap.md b/changes/controller-inline-remap.md new file mode 100644 index 0000000..b3f79ba --- /dev/null +++ b/changes/controller-inline-remap.md @@ -0,0 +1,4 @@ +type: changed +area: overlay + +- Expanded the `Alt+C` controller modal into an inline config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions. diff --git a/config.example.jsonc b/config.example.jsonc index c1ce0ee..ec1000d 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -53,13 +53,13 @@ // ========================================== // Controller Support // Gamepad support for the visible overlay while keyboard-only mode is active. - // Use the selection modal to save a preferred controller by id for future launches. + // Use Alt+C to pick a preferred controller and remap actions inline with learn mode. // Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller. // Override controller.buttonIndices when your pad reports non-standard raw button numbers. // ========================================== "controller": { "enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false - "preferredGamepadId": "", // Preferred controller id saved from the controller selection modal. + "preferredGamepadId": "", // Preferred controller id saved from the controller config modal. "preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics. "smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false "scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input. @@ -81,22 +81,64 @@ "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": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY - "leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY - "rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY - "rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY - } // Bindings setting. + "toggleLookup": { + "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. If kind is "axis", direction is required. + "closeLookup": { + "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. If kind is "axis", direction is required. + "toggleKeyboardOnlyMode": { + "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. If kind is "axis", direction is required. + "mineCard": { + "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. If kind is "axis", direction is required. + "quitMpv": { + "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. If kind is "axis", direction is required. + "previousAudio": { + "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. 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. If kind is "axis", direction is required. + "playCurrentAudio": { + "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. If kind is "axis", direction is required. + "toggleMpvPause": { + "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. 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. + "dpadFallback": "horizontal" // 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 left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. + "leftStickVertical": { + "kind": "axis", // Analog binding input source kind. Values: none | axis + "axisIndex": 1, // Raw axis index captured for this analog controller action. + "dpadFallback": "vertical" // 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 primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. + "rightStickHorizontal": { + "kind": "axis", // Analog binding input source kind. Values: none | axis + "axisIndex": 3, // 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 reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. + "rightStickVertical": { + "kind": "axis", // Analog binding input source kind. Values: none | axis + "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. + } // 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/changelog.md b/docs-site/changelog.md index 6f0ffad..cca52a4 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -11,7 +11,7 @@ - Added Chrome Gamepad API controller support for keyboard-only overlay mode. - Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation. - Added smooth, slower popup scrolling for controller navigation. -- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection. +- Expanded `Alt+C` into a controller config/remap modal with preferred-controller saving, inline learn mode, and kept `Alt+Shift+C` for raw input debugging. - Added a transient in-overlay controller-detected indicator when a controller is first found. - Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes. - Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 4a13115..6a4de49 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -514,8 +514,10 @@ Important behavior: - Controller input is only active while keyboard-only mode is enabled. - Keyboard-only mode continues to work normally without a controller. - By default SubMiner uses the first connected controller. -- `Alt+C` opens the controller selection modal and saves the selected controller for future launches. +- `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. @@ -547,19 +549,19 @@ Important behavior: "rightTrigger": 7 }, "bindings": { - "toggleLookup": "buttonSouth", - "closeLookup": "buttonEast", - "toggleKeyboardOnlyMode": "buttonNorth", - "mineCard": "buttonWest", - "quitMpv": "select", - "previousAudio": "none", - "nextAudio": "rightShoulder", - "playCurrentAudio": "leftShoulder", - "toggleMpvPause": "leftStickPress", - "leftStickHorizontal": "leftStickX", - "leftStickVertical": "leftStickY", - "rightStickHorizontal": "rightStickX", - "rightStickVertical": "rightStickY" + "toggleLookup": { "kind": "button", "buttonIndex": 0 }, + "closeLookup": { "kind": "button", "buttonIndex": 1 }, + "toggleKeyboardOnlyMode": { "kind": "button", "buttonIndex": 3 }, + "mineCard": { "kind": "button", "buttonIndex": 2 }, + "quitMpv": { "kind": "button", "buttonIndex": 6 }, + "previousAudio": { "kind": "none" }, + "nextAudio": { "kind": "button", "buttonIndex": 5 }, + "playCurrentAudio": { "kind": "button", "buttonIndex": 4 }, + "toggleMpvPause": { "kind": "button", "buttonIndex": 9 }, + "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" }, + "leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" }, + "rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" }, + "rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" } } } } @@ -581,10 +583,28 @@ Default logical mapping: - `L3`: toggle mpv pause - `L2` / `R2`: unbound by default +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 c1ce0ee..ec1000d 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -53,13 +53,13 @@ // ========================================== // Controller Support // Gamepad support for the visible overlay while keyboard-only mode is active. - // Use the selection modal to save a preferred controller by id for future launches. + // Use Alt+C to pick a preferred controller and remap actions inline with learn mode. // Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller. // Override controller.buttonIndices when your pad reports non-standard raw button numbers. // ========================================== "controller": { "enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false - "preferredGamepadId": "", // Preferred controller id saved from the controller selection modal. + "preferredGamepadId": "", // Preferred controller id saved from the controller config modal. "preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics. "smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false "scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input. @@ -81,22 +81,64 @@ "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": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger - "leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY - "leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY - "rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY - "rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY - } // Bindings setting. + "toggleLookup": { + "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. If kind is "axis", direction is required. + "closeLookup": { + "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. If kind is "axis", direction is required. + "toggleKeyboardOnlyMode": { + "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. If kind is "axis", direction is required. + "mineCard": { + "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. If kind is "axis", direction is required. + "quitMpv": { + "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. If kind is "axis", direction is required. + "previousAudio": { + "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. 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. If kind is "axis", direction is required. + "playCurrentAudio": { + "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. If kind is "axis", direction is required. + "toggleMpvPause": { + "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. 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. + "dpadFallback": "horizontal" // 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 left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. + "leftStickVertical": { + "kind": "axis", // Analog binding input source kind. Values: none | axis + "axisIndex": 1, // Raw axis index captured for this analog controller action. + "dpadFallback": "vertical" // 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 primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. + "rightStickHorizontal": { + "kind": "axis", // Analog binding input source kind. Values: none | axis + "axisIndex": 3, // 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 reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. + "rightStickVertical": { + "kind": "axis", // Analog binding input source kind. Values: none | axis + "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. + } // 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/shortcuts.md b/docs-site/shortcuts.md index 1f74542..742a382 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -75,8 +75,8 @@ These overlay-local shortcuts are fixed and open controller utilities for the Ch | Shortcut | Action | Configurable | | ------------- | ------------------------------ | ------------ | -| `Alt+C` | Open controller selection modal | Fixed | -| `Alt+Shift+C` | Open controller debug modal | Fixed | +| `Alt+C` | Open controller config + remap modal | Fixed | +| `Alt+Shift+C` | Open controller debug modal | Fixed | Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller. diff --git a/docs-site/usage.md b/docs-site/usage.md index 5c56753..71e3b00 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -254,10 +254,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro 1. Connect a controller before or after launching SubMiner. 2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. -3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup. -4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. +3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline. +4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. +5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. +6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. -By default SubMiner uses the first connected controller. Press `Alt+C` in the overlay to open the controller selection modal and persist your preferred controller across sessions. Press `Alt+Shift+C` to open a live debug modal showing raw axes and button values. +By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads. ### Default Button Mapping @@ -278,10 +280,11 @@ By default SubMiner uses the first connected controller. Press `Alt+C` in the ov | Input | Action | | ----- | ------ | | Left stick horizontal | Move token selection left/right | -| Left stick vertical | Smooth scroll Yomitan popup | -| Right stick horizontal | Jump inside popup (horizontal) | -| Right stick vertical | Smooth scroll popup (vertical) | -| D-pad | Fallback for stick navigation | +| Left stick vertical | Scroll Yomitan popup | +| Right stick vertical | Jump through Yomitan popup | +| D-pad | Fallback for stick navigation when configured | + +Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input. All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options. diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 92ed3e0..e559de6 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1168,12 +1168,103 @@ test('parses controller settings with logical bindings and tuning knobs', () => assert.equal(config.controller.repeatIntervalMs, 70); assert.equal(config.controller.buttonIndices.select, 6); assert.equal(config.controller.buttonIndices.leftStickPress, 9); - assert.equal(config.controller.bindings.toggleLookup, 'buttonWest'); - assert.equal(config.controller.bindings.quitMpv, 'select'); - assert.equal(config.controller.bindings.playCurrentAudio, 'none'); - assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress'); - assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX'); - assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY'); + assert.deepEqual(config.controller.bindings.toggleLookup, { kind: 'button', buttonIndex: 2 }); + assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 }); + assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' }); + assert.deepEqual(config.controller.bindings.toggleMpvPause, { kind: 'button', buttonIndex: 9 }); + assert.deepEqual(config.controller.bindings.leftStickHorizontal, { + kind: 'axis', + axisIndex: 3, + dpadFallback: 'horizontal', + }); + assert.deepEqual(config.controller.bindings.rightStickVertical, { + kind: 'axis', + axisIndex: 1, + dpadFallback: 'none', + }); +}); + +test('parses descriptor-based controller bindings', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "bindings": { + "toggleLookup": { "kind": "button", "buttonIndex": 11 }, + "closeLookup": { "kind": "axis", "axisIndex": 4, "direction": "negative" }, + "playCurrentAudio": { "kind": "none" }, + "leftStickHorizontal": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }, + "leftStickVertical": { "kind": "axis", "axisIndex": 2, "dpadFallback": "vertical" } + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.deepEqual(config.controller.bindings.toggleLookup, { + kind: 'button', + buttonIndex: 11, + }); + assert.deepEqual(config.controller.bindings.closeLookup, { + kind: 'axis', + axisIndex: 4, + direction: 'negative', + }); + assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' }); + assert.deepEqual(config.controller.bindings.leftStickHorizontal, { + kind: 'axis', + axisIndex: 7, + dpadFallback: 'none', + }); + assert.deepEqual(config.controller.bindings.leftStickVertical, { + kind: 'axis', + axisIndex: 2, + dpadFallback: 'vertical', + }); +}); + +test('controller descriptor config rejects malformed binding objects', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "bindings": { + "toggleLookup": { "kind": "button", "buttonIndex": -1 }, + "closeLookup": { "kind": "axis", "axisIndex": 1, "direction": "sideways" }, + "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "diagonal" } + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.deepEqual( + config.controller.bindings.toggleLookup, + DEFAULT_CONFIG.controller.bindings.toggleLookup, + ); + assert.deepEqual( + config.controller.bindings.closeLookup, + DEFAULT_CONFIG.controller.bindings.closeLookup, + ); + assert.deepEqual( + config.controller.bindings.leftStickHorizontal, + DEFAULT_CONFIG.controller.bindings.leftStickHorizontal, + ); + assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true); + assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true); + assert.equal( + warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'), + true, + ); }); test('controller positive-number tuning rejects sub-unit values that floor to zero', () => { @@ -1825,6 +1916,24 @@ test('template generator includes known keys', () => { output, /"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/, ); + assert.match( + output, + /"preferredGamepadId": "",? \/\/ Preferred controller id saved from the controller config modal\./, + ); + assert.match( + output, + /"toggleLookup": \{\s*"kind": "button"[\s\S]*\},? \/\/ Controller binding descriptor for toggling lookup\. Use Alt\+C learn mode or set a raw button\/axis descriptor manually\./, + ); + assert.match( + output, + /"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"/); + assert.match( + output, + /"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/, + ); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); assert.match( output, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index fdaaa1f..af11bc8 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -58,19 +58,19 @@ export const CORE_DEFAULT_CONFIG: Pick< rightTrigger: 7, }, bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'none', - nextAudio: 'rightShoulder', - playCurrentAudio: 'leftShoulder', - toggleMpvPause: 'leftStickPress', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', + toggleLookup: { kind: 'button', buttonIndex: 0 }, + closeLookup: { kind: 'button', buttonIndex: 1 }, + toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 }, + mineCard: { kind: 'button', buttonIndex: 2 }, + quitMpv: { kind: 'button', buttonIndex: 6 }, + previousAudio: { kind: 'none' }, + nextAudio: { kind: 'button', buttonIndex: 5 }, + playCurrentAudio: { kind: 'button', buttonIndex: 4 }, + toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, + leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, + rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, + rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, }, }, shortcuts: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 3cfacbc..8f50a7f 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -4,20 +4,76 @@ import { ConfigOptionRegistryEntry } from './shared'; export function buildCoreConfigOptionRegistry( defaultConfig: ResolvedConfig, ): ConfigOptionRegistryEntry[] { - const controllerButtonEnumValues = [ - 'none', - 'select', - 'buttonSouth', - 'buttonEast', - 'buttonNorth', - 'buttonWest', - 'leftShoulder', - 'rightShoulder', - 'leftStickPress', - 'rightStickPress', - 'leftTrigger', - 'rightTrigger', - ]; + const discreteBindings = [ + { + id: 'toggleLookup', + defaultValue: defaultConfig.controller.bindings.toggleLookup, + description: 'Controller binding descriptor for toggling lookup.', + }, + { + id: 'closeLookup', + defaultValue: defaultConfig.controller.bindings.closeLookup, + description: 'Controller binding descriptor for closing lookup.', + }, + { + id: 'toggleKeyboardOnlyMode', + defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode, + description: 'Controller binding descriptor for toggling keyboard-only mode.', + }, + { + id: 'mineCard', + defaultValue: defaultConfig.controller.bindings.mineCard, + description: 'Controller binding descriptor for mining the active card.', + }, + { + id: 'quitMpv', + defaultValue: defaultConfig.controller.bindings.quitMpv, + description: 'Controller binding descriptor for quitting mpv.', + }, + { + id: 'previousAudio', + defaultValue: defaultConfig.controller.bindings.previousAudio, + description: 'Controller binding descriptor for previous Yomitan audio.', + }, + { + id: 'nextAudio', + defaultValue: defaultConfig.controller.bindings.nextAudio, + description: 'Controller binding descriptor for next Yomitan audio.', + }, + { + id: 'playCurrentAudio', + defaultValue: defaultConfig.controller.bindings.playCurrentAudio, + description: 'Controller binding descriptor for playing the current Yomitan audio.', + }, + { + id: 'toggleMpvPause', + defaultValue: defaultConfig.controller.bindings.toggleMpvPause, + description: 'Controller binding descriptor for toggling mpv play/pause.', + }, + ] as const; + + const axisBindings = [ + { + id: 'leftStickHorizontal', + defaultValue: defaultConfig.controller.bindings.leftStickHorizontal, + description: 'Axis binding descriptor used for left/right token selection.', + }, + { + id: 'leftStickVertical', + defaultValue: defaultConfig.controller.bindings.leftStickVertical, + description: 'Axis binding descriptor used for primary popup scrolling.', + }, + { + id: 'rightStickHorizontal', + defaultValue: defaultConfig.controller.bindings.rightStickHorizontal, + description: 'Axis binding descriptor reserved for alternate right-stick mappings.', + }, + { + id: 'rightStickVertical', + defaultValue: defaultConfig.controller.bindings.rightStickVertical, + description: 'Axis binding descriptor used for popup page jumps.', + }, + ] as const; return [ { @@ -37,7 +93,7 @@ export function buildCoreConfigOptionRegistry( path: 'controller.preferredGamepadId', kind: 'string', defaultValue: defaultConfig.controller.preferredGamepadId, - description: 'Preferred controller id saved from the controller selection modal.', + description: 'Preferred controller id saved from the controller config modal.', }, { path: 'controller.preferredGamepadLabel', @@ -96,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', @@ -163,96 +226,79 @@ export function buildCoreConfigOptionRegistry( description: 'Raw button index used for controller R2 input.', }, { - path: 'controller.bindings.toggleLookup', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.toggleLookup, - description: 'Controller binding for toggling lookup.', - }, - { - path: 'controller.bindings.closeLookup', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.closeLookup, - description: 'Controller binding for closing lookup.', - }, - { - path: 'controller.bindings.toggleKeyboardOnlyMode', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode, - description: 'Controller binding for toggling keyboard-only mode.', - }, - { - path: 'controller.bindings.mineCard', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.mineCard, - description: 'Controller binding for mining the active card.', - }, - { - path: 'controller.bindings.quitMpv', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.quitMpv, - description: 'Controller binding for quitting mpv.', - }, - { - path: 'controller.bindings.previousAudio', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.previousAudio, - description: 'Controller binding for previous Yomitan audio.', - }, - { - path: 'controller.bindings.nextAudio', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.nextAudio, - description: 'Controller binding for next Yomitan audio.', - }, - { - path: 'controller.bindings.playCurrentAudio', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.playCurrentAudio, - description: 'Controller binding for playing the current Yomitan audio.', - }, - { - path: 'controller.bindings.toggleMpvPause', - kind: 'enum', - enumValues: controllerButtonEnumValues, - defaultValue: defaultConfig.controller.bindings.toggleMpvPause, - description: 'Controller binding for toggling mpv play/pause.', - }, - { - path: 'controller.bindings.leftStickHorizontal', - kind: 'enum', - enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], - defaultValue: defaultConfig.controller.bindings.leftStickHorizontal, - description: 'Axis binding used for left/right token selection.', - }, - { - path: 'controller.bindings.leftStickVertical', - kind: 'enum', - enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], - defaultValue: defaultConfig.controller.bindings.leftStickVertical, - description: 'Axis binding used for primary popup scrolling.', - }, - { - path: 'controller.bindings.rightStickHorizontal', - kind: 'enum', - enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], - defaultValue: defaultConfig.controller.bindings.rightStickHorizontal, - description: 'Axis binding reserved for alternate right-stick mappings.', - }, - { - path: 'controller.bindings.rightStickVertical', - kind: 'enum', - enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], - defaultValue: defaultConfig.controller.bindings.rightStickVertical, - description: 'Axis binding used for popup page jumps.', + 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. 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. When kind is "axis", set both axisIndex and direction.', + }, + { + path: `controller.bindings.${binding.id}.buttonIndex`, + kind: 'number' as const, + defaultValue: + binding.defaultValue.kind === 'button' ? binding.defaultValue.buttonIndex : undefined, + description: 'Raw button index captured for this discrete controller action.', + }, + { + path: `controller.bindings.${binding.id}.axisIndex`, + kind: 'number' as const, + defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined, + description: 'Raw axis index captured for this discrete controller action.', + }, + { + 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. Required when kind is "axis".', + }, + ]), + ...axisBindings.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 axis descriptor manually.`, + }, + { + path: `controller.bindings.${binding.id}.kind`, + kind: 'enum' as const, + enumValues: ['none', 'axis'], + defaultValue: binding.defaultValue.kind, + description: 'Analog binding input source kind.', + }, + { + path: `controller.bindings.${binding.id}.axisIndex`, + kind: 'number' as const, + defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined, + description: 'Raw axis index captured for this analog controller action.', + }, + { + path: `controller.bindings.${binding.id}.dpadFallback`, + kind: 'enum' as const, + enumValues: ['none', 'horizontal', 'vertical'], + defaultValue: + binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined, + description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.', + }, + ]), { path: 'texthooker.launchAtStartup', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index d25e8d1..414838d 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -38,7 +38,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'Controller Support', description: [ 'Gamepad support for the visible overlay while keyboard-only mode is active.', - 'Use the selection modal to save a preferred controller by id for future launches.', + 'Use Alt+C to pick a preferred controller and remap actions inline with learn mode.', 'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.', 'Override controller.buttonIndices when your pad reports non-standard raw button numbers.', ], diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 263d034..feac20c 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -1,28 +1,141 @@ +import type { + ControllerAxisBinding, + ControllerAxisBindingConfig, + ControllerAxisDirection, + ControllerButtonBinding, + ControllerButtonIndicesConfig, + ControllerDpadFallback, + ControllerDiscreteBindingConfig, + ResolvedControllerAxisBinding, + ResolvedControllerDiscreteBinding, +} from '../../types'; import { ResolveContext } from './context'; import { asBoolean, asNumber, asString, isObject } from './shared'; +const CONTROLLER_BUTTON_BINDINGS = [ + 'none', + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', +] as const; + +const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const; + +const CONTROLLER_AXIS_INDEX_BY_BINDING: Record = { + leftStickX: 0, + leftStickY: 1, + rightStickX: 3, + rightStickY: 4, +}; + +const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record< + Exclude, + keyof Required +> = { + select: 'select', + buttonSouth: 'buttonSouth', + buttonEast: 'buttonEast', + buttonNorth: 'buttonNorth', + buttonWest: 'buttonWest', + leftShoulder: 'leftShoulder', + rightShoulder: 'rightShoulder', + leftStickPress: 'leftStickPress', + rightStickPress: 'rightStickPress', + leftTrigger: 'leftTrigger', + rightTrigger: 'rightTrigger', +}; + +const CONTROLLER_AXIS_FALLBACK_BY_SLOT = { + leftStickHorizontal: 'horizontal', + leftStickVertical: 'vertical', + rightStickHorizontal: 'none', + rightStickVertical: 'none', +} as const satisfies Record; + +function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection { + return value === 'negative' || value === 'positive'; +} + +function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback { + return value === 'none' || value === 'horizontal' || value === 'vertical'; +} + +function resolveLegacyDiscreteBinding( + value: ControllerButtonBinding, + buttonIndices: Required, +): ResolvedControllerDiscreteBinding { + if (value === 'none') { + return { kind: 'none' }; + } + return { + kind: 'button', + buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]], + }; +} + +function resolveLegacyAxisBinding( + value: ControllerAxisBinding, + slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, +): ResolvedControllerAxisBinding { + return { + kind: 'axis', + axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value], + dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], + }; +} + +function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null { + if (!isObject(value) || typeof value.kind !== 'string') return null; + if (value.kind === 'none') { + return { kind: 'none' }; + } + if (value.kind === 'button') { + return typeof value.buttonIndex === 'number' && Number.isInteger(value.buttonIndex) && value.buttonIndex >= 0 + ? { kind: 'button', buttonIndex: value.buttonIndex } + : null; + } + if (value.kind === 'axis') { + return typeof value.axisIndex === 'number' && + Number.isInteger(value.axisIndex) && + value.axisIndex >= 0 && + isControllerAxisDirection(value.direction) + ? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction } + : null; + } + return null; +} + +function parseAxisBindingObject( + value: unknown, + slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, +): ResolvedControllerAxisBinding | null { + if (isObject(value) && value.kind === 'none') { + return { kind: 'none' }; + } + if (!isObject(value) || value.kind !== 'axis') return null; + if (typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0) { + return null; + } + if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) { + return null; + } + return { + kind: 'axis', + axisIndex: value.axisIndex, + dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], + }; +} + export function applyCoreDomainConfig(context: ResolveContext): void { const { src, resolved, warn } = context; - const controllerButtonBindings = [ - 'none', - 'select', - 'buttonSouth', - 'buttonEast', - 'buttonNorth', - 'buttonWest', - 'leftShoulder', - 'rightShoulder', - 'leftStickPress', - 'rightStickPress', - 'leftTrigger', - 'rightTrigger', - ] as const; - const controllerAxisBindings = [ - 'leftStickX', - 'leftStickY', - 'rightStickX', - 'rightStickY', - ] as const; if (isObject(src.texthooker)) { const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); @@ -251,19 +364,27 @@ export function applyCoreDomainConfig(context: ResolveContext): void { ] as const; for (const key of buttonBindingKeys) { - const value = asString(src.controller.bindings[key]); + const bindingValue = src.controller.bindings[key]; + const legacyValue = asString(bindingValue); if ( - value !== undefined && - controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number]) + legacyValue !== undefined && + CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number]) ) { - resolved.controller.bindings[key] = - value as (typeof resolved.controller.bindings)[typeof key]; - } else if (src.controller.bindings[key] !== undefined) { + resolved.controller.bindings[key] = resolveLegacyDiscreteBinding( + legacyValue as ControllerButtonBinding, + resolved.controller.buttonIndices, + ); + continue; + } + const parsedObject = parseDiscreteBindingObject(bindingValue); + if (parsedObject) { + resolved.controller.bindings[key] = parsedObject; + } else if (bindingValue !== undefined) { warn( `controller.bindings.${key}`, - src.controller.bindings[key], + bindingValue, resolved.controller.bindings[key], - `Expected one of: ${controllerButtonBindings.join(', ')}.`, + "Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.", ); } } @@ -276,19 +397,31 @@ export function applyCoreDomainConfig(context: ResolveContext): void { ] as const; for (const key of axisBindingKeys) { - const value = asString(src.controller.bindings[key]); + const bindingValue = src.controller.bindings[key]; + const legacyValue = asString(bindingValue); if ( - value !== undefined && - controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number]) + legacyValue !== undefined && + CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number]) ) { - resolved.controller.bindings[key] = - value as (typeof resolved.controller.bindings)[typeof key]; - } else if (src.controller.bindings[key] !== undefined) { + resolved.controller.bindings[key] = resolveLegacyAxisBinding( + legacyValue as ControllerAxisBinding, + key, + ); + continue; + } + if (legacyValue === 'none') { + resolved.controller.bindings[key] = { kind: 'none' }; + continue; + } + const parsedObject = parseAxisBindingObject(bindingValue, key); + if (parsedObject) { + resolved.controller.bindings[key] = parsedObject; + } else if (bindingValue !== undefined) { warn( `controller.bindings.${key}`, - src.controller.bindings[key], + bindingValue, resolved.controller.bindings[key], - `Expected one of: ${controllerAxisBindings.join(', ')}.`, + "Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.", ); } } diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 69d40ad..ed80090 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -33,6 +33,50 @@ function createFakeIpcRegistrar(): { }; } +function createControllerConfigFixture() { + return { + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto' as const, + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: { kind: 'button' as const, buttonIndex: 0 }, + closeLookup: { kind: 'button' as const, buttonIndex: 1 }, + toggleKeyboardOnlyMode: { kind: 'button' as const, buttonIndex: 3 }, + mineCard: { kind: 'button' as const, buttonIndex: 2 }, + quitMpv: { kind: 'button' as const, buttonIndex: 6 }, + previousAudio: { kind: 'button' as const, buttonIndex: 4 }, + nextAudio: { kind: 'button' as const, buttonIndex: 5 }, + playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 }, + toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 }, + leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const }, + leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const }, + rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const }, + rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const }, + }, + }; +} + test('createIpcDepsRuntime wires AniList handlers', async () => { const calls: string[] = []; const deps = createIpcDepsRuntime({ @@ -53,47 +97,8 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), - getControllerConfig: () => ({ - enabled: true, - preferredGamepadId: '', - preferredGamepadLabel: '', - smoothScroll: true, - scrollPixelsPerSecond: 960, - horizontalJumpPixels: 160, - stickDeadzone: 0.2, - triggerInputMode: 'auto', - triggerDeadzone: 0.5, - repeatDelayMs: 220, - repeatIntervalMs: 80, - buttonIndices: { - select: 6, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, - }, - bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'leftShoulder', - nextAudio: 'rightShoulder', - playCurrentAudio: 'rightTrigger', - toggleMpvPause: 'leftTrigger', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', - }, - }), + getControllerConfig: () => createControllerConfigFixture(), + saveControllerConfig: () => {}, saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover', getMpvClient: () => null, @@ -159,47 +164,8 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), - getControllerConfig: () => ({ - enabled: true, - preferredGamepadId: '', - preferredGamepadLabel: '', - smoothScroll: true, - scrollPixelsPerSecond: 960, - horizontalJumpPixels: 160, - stickDeadzone: 0.2, - triggerInputMode: 'auto', - triggerDeadzone: 0.5, - repeatDelayMs: 220, - repeatIntervalMs: 80, - buttonIndices: { - select: 6, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, - }, - bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'leftShoulder', - nextAudio: 'rightShoulder', - playCurrentAudio: 'rightTrigger', - toggleMpvPause: 'leftTrigger', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', - }, - }), + getControllerConfig: () => createControllerConfigFixture(), + saveControllerConfig: () => {}, saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', @@ -299,47 +265,10 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), - getControllerConfig: () => ({ - enabled: true, - preferredGamepadId: '', - preferredGamepadLabel: '', - smoothScroll: true, - scrollPixelsPerSecond: 960, - horizontalJumpPixels: 160, - stickDeadzone: 0.2, - triggerInputMode: 'auto', - triggerDeadzone: 0.5, - repeatDelayMs: 220, - repeatIntervalMs: 80, - buttonIndices: { - select: 6, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, - }, - bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'leftShoulder', - nextAudio: 'rightShoulder', - playCurrentAudio: 'rightTrigger', - toggleMpvPause: 'leftTrigger', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', - }, - }), + getControllerConfig: () => createControllerConfigFixture(), + saveControllerConfig: (update) => { + controllerSaves.push(update); + }, saveControllerPreference: (update) => { controllerSaves.push(update); }, @@ -400,47 +329,8 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), - getControllerConfig: () => ({ - enabled: true, - preferredGamepadId: '', - preferredGamepadLabel: '', - smoothScroll: true, - scrollPixelsPerSecond: 960, - horizontalJumpPixels: 160, - stickDeadzone: 0.2, - triggerInputMode: 'auto', - triggerDeadzone: 0.5, - repeatDelayMs: 220, - repeatIntervalMs: 80, - buttonIndices: { - select: 6, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, - }, - bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'leftShoulder', - nextAudio: 'rightShoulder', - playCurrentAudio: 'rightTrigger', - toggleMpvPause: 'leftTrigger', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', - }, - }), + getControllerConfig: () => createControllerConfigFixture(), + saveControllerConfig: async () => {}, saveControllerPreference: async (update) => { await Promise.resolve(); controllerSaves.push(update); @@ -486,6 +376,85 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon ]); }); +test('registerIpcHandlers awaits saveControllerConfig through request-response IPC', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const controllerConfigSaves: unknown[] = []; + registerIpcHandlers( + { + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleDevTools: () => {}, + getVisibleOverlayVisibility: () => false, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => false, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabStatus: () => ({ available: false, enabled: false, path: null }), + setMecabEnabled: () => {}, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getControllerConfig: () => createControllerConfigFixture(), + saveControllerConfig: async (update) => { + await Promise.resolve(); + controllerConfigSaves.push(update); + }, + saveControllerPreference: async () => {}, + getSecondarySubMode: () => 'hover', + getCurrentSecondarySub: () => '', + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + getAnilistStatus: () => ({}), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + registrar, + ); + + const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig); + assert.ok(saveHandler); + + await assert.rejects( + async () => { + await saveHandler!({}, { bindings: { toggleLookup: { kind: 'button', buttonIndex: -1 } } }); + }, + /Invalid controller config payload/, + ); + + await saveHandler!({}, { + preferredGamepadId: 'pad-2', + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' }, + leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' }, + }, + }); + + assert.deepEqual(controllerConfigSaves, [ + { + preferredGamepadId: 'pad-2', + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' }, + leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' }, + }, + }, + ]); +}); + test('registerIpcHandlers rejects malformed controller preference payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers( @@ -508,47 +477,8 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), - getControllerConfig: () => ({ - enabled: true, - preferredGamepadId: '', - preferredGamepadLabel: '', - smoothScroll: true, - scrollPixelsPerSecond: 960, - horizontalJumpPixels: 160, - stickDeadzone: 0.2, - triggerInputMode: 'auto', - triggerDeadzone: 0.5, - repeatDelayMs: 220, - repeatIntervalMs: 80, - buttonIndices: { - select: 6, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, - }, - bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'leftShoulder', - nextAudio: 'rightShoulder', - playCurrentAudio: 'rightTrigger', - toggleMpvPause: 'leftTrigger', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', - }, - }), + getControllerConfig: () => createControllerConfigFixture(), + saveControllerConfig: async () => {}, saveControllerPreference: async () => {}, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 0568950..d6e82ec 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -1,6 +1,7 @@ import electron from 'electron'; import type { IpcMainEvent } from 'electron'; import type { + ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, RuntimeOptionId, @@ -12,6 +13,7 @@ import type { import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { parseMpvCommand, + parseControllerConfigUpdate, parseControllerPreferenceUpdate, parseOptionalForwardingOptions, parseOverlayHostedModal, @@ -49,6 +51,7 @@ export interface IpcServiceDeps { getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; getControllerConfig: () => ResolvedControllerConfig; + saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getCurrentSecondarySub: () => string; @@ -114,6 +117,7 @@ export interface IpcDepsRuntimeOptions { getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; getControllerConfig: () => ResolvedControllerConfig; + saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getMpvClient: () => MpvClientLike | null; @@ -167,6 +171,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getKeybindings: options.getKeybindings, getConfiguredShortcuts: options.getConfiguredShortcuts, getControllerConfig: options.getControllerConfig, + saveControllerConfig: options.saveControllerConfig, saveControllerPreference: options.saveControllerPreference, getSecondarySubMode: options.getSecondarySubMode, getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '', @@ -276,6 +281,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); + ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => { + const parsedUpdate = parseControllerConfigUpdate(update); + if (!parsedUpdate) { + throw new Error('Invalid controller config payload'); + } + await deps.saveControllerConfig(parsedUpdate); + }); + ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { return deps.getMecabStatus(); }); diff --git a/src/main.ts b/src/main.ts index ae6cf13..c8a25f1 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) { @@ -3456,6 +3457,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getKeybindings: () => appState.keybindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), getControllerConfig: () => getResolvedConfig().controller, + saveControllerConfig: (update) => { + const currentRawConfig = configService.getRawConfig(); + configService.patchRawConfig({ + controller: applyControllerConfigUpdate(currentRawConfig.controller, update), + }); + }, saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { configService.patchRawConfig({ controller: { diff --git a/src/main/controller-config-update.test.ts b/src/main/controller-config-update.test.ts new file mode 100644 index 0000000..73d0ab4 --- /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 0000000..de58a89 --- /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/main/dependencies.ts b/src/main/dependencies.ts index d5d40bc..6c83d5f 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams { getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; + saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; getMpvClient: IpcDepsRuntimeOptions['getMpvClient']; @@ -216,6 +217,7 @@ export function createMainIpcRuntimeServiceDeps( getKeybindings: params.getKeybindings, getConfiguredShortcuts: params.getConfiguredShortcuts, getControllerConfig: params.getControllerConfig, + saveControllerConfig: params.saveControllerConfig, saveControllerPreference: params.saveControllerPreference, focusMainWindow: params.focusMainWindow ?? (() => {}), getSecondarySubMode: params.getSecondarySubMode, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 4665e9e..218c645 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -52,6 +52,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getKeybindings: () => [], getConfiguredShortcuts: () => ({}) as never, getControllerConfig: () => ({}) as never, + saveControllerConfig: () => {}, saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover' as never, getMpvClient: () => null, diff --git a/src/preload.ts b/src/preload.ts index 7b0457a..878b6f8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -48,6 +48,7 @@ import type { OverlayContentMeasurement, ShortcutsConfig, ConfigHotReloadPayload, + ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, } from './types'; @@ -209,6 +210,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), getControllerConfig: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), + saveControllerConfig: (update: ControllerConfigUpdate): Promise => + ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update), saveControllerPreference: (update: ControllerPreferenceUpdate): Promise => ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update), diff --git a/src/renderer/handlers/controller-binding-capture.test.ts b/src/renderer/handlers/controller-binding-capture.test.ts new file mode 100644 index 0000000..05802a9 --- /dev/null +++ b/src/renderer/handlers/controller-binding-capture.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createControllerBindingCapture } from './controller-binding-capture.js'; + +function createSnapshot( + overrides: { + axes?: number[]; + buttons?: Array<{ value: number; pressed?: boolean; touched?: boolean }>; + } = {}, +) { + return { + axes: overrides.axes ?? [0, 0, 0, 0, 0], + buttons: + overrides.buttons ?? + Array.from({ length: 12 }, () => ({ + value: 0, + pressed: false, + touched: false, + })), + }; +} + +test('controller binding capture waits for neutral-to-active button edge', () => { + const capture = createControllerBindingCapture({ + triggerDeadzone: 0.5, + stickDeadzone: 0.2, + }); + + const heldButtons = createSnapshot({ + buttons: [{ value: 1, pressed: true, touched: true }], + }); + + capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons); + + assert.equal(capture.poll(heldButtons), null); + + const neutralButtons = createSnapshot(); + assert.equal(capture.poll(neutralButtons), null); + + const freshPress = createSnapshot({ + buttons: [{ value: 1, pressed: true, touched: true }], + }); + assert.deepEqual(capture.poll(freshPress), { + actionId: 'toggleLookup', + bindingType: 'discrete', + binding: { kind: 'button', buttonIndex: 0 }, + }); +}); + +test('controller binding capture records fresh axis direction for discrete learn mode', () => { + const capture = createControllerBindingCapture({ + triggerDeadzone: 0.5, + stickDeadzone: 0.2, + }); + + capture.arm({ actionId: 'closeLookup', bindingType: 'discrete' }, createSnapshot()); + + assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, -0.8] })), { + actionId: 'closeLookup', + bindingType: 'discrete', + binding: { kind: 'axis', axisIndex: 3, direction: 'negative' }, + }); +}); + +test('controller binding capture ignores analog drift inside deadzone', () => { + const capture = createControllerBindingCapture({ + triggerDeadzone: 0.5, + stickDeadzone: 0.3, + }); + + capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot()); + + assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null); + assert.equal(capture.isArmed(), true); +}); + +test('controller binding capture emits axis binding for continuous learn mode', () => { + const capture = createControllerBindingCapture({ + triggerDeadzone: 0.5, + stickDeadzone: 0.2, + }); + + capture.arm( + { + actionId: 'leftStickHorizontal', + bindingType: 'axis', + dpadFallback: 'horizontal', + }, + createSnapshot(), + ); + + assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, 0.9] })), { + actionId: 'leftStickHorizontal', + bindingType: 'axis', + 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 new file mode 100644 index 0000000..eb033b3 --- /dev/null +++ b/src/renderer/handlers/controller-binding-capture.ts @@ -0,0 +1,194 @@ +import type { + ControllerDpadFallback, + ResolvedControllerAxisBinding, + ResolvedControllerDiscreteBinding, +} from '../../types'; + +type ControllerButtonState = { + value: number; + pressed?: boolean; + touched?: boolean; +}; + +type ControllerBindingCaptureSnapshot = { + axes: readonly number[]; + buttons: readonly ControllerButtonState[]; +}; + +type ControllerBindingCaptureTarget = + | { + actionId: string; + bindingType: 'discrete'; + } + | { + actionId: string; + bindingType: 'axis'; + dpadFallback: ControllerDpadFallback; + } + | { + actionId: string; + bindingType: 'dpad'; + }; + +type ControllerBindingCaptureResult = + | { + actionId: string; + bindingType: 'discrete'; + binding: ResolvedControllerDiscreteBinding; + } + | { + actionId: string; + bindingType: 'axis'; + binding: ResolvedControllerAxisBinding; + } + | { + actionId: string; + bindingType: 'dpad'; + dpadDirection: ControllerDpadFallback; + }; + +function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean { + if (!button) return false; + return Boolean(button.pressed) || button.value >= triggerDeadzone; +} + +function getAxisDirection( + value: number | undefined, + activationThreshold: number, +): 'negative' | 'positive' | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + if (Math.abs(value) < activationThreshold) return null; + return value > 0 ? 'positive' : 'negative'; +} + +const DPAD_BUTTON_INDICES = [12, 13, 14, 15] as const; + +export function createControllerBindingCapture(options: { + triggerDeadzone: number; + stickDeadzone: number; +}) { + let target: ControllerBindingCaptureTarget | null = null; + const blockedButtons = new Set(); + const blockedAxisDirections = new Set(); + + function resetBlockedState(snapshot: ControllerBindingCaptureSnapshot): void { + blockedButtons.clear(); + blockedAxisDirections.clear(); + + snapshot.buttons.forEach((button, index) => { + if (isActiveButton(button, options.triggerDeadzone)) { + blockedButtons.add(index); + } + }); + + const activationThreshold = Math.max(options.stickDeadzone, 0.55); + snapshot.axes.forEach((value, index) => { + const direction = getAxisDirection(value, activationThreshold); + if (direction) { + blockedAxisDirections.add(`${index}:${direction}`); + } + }); + } + + function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void { + target = nextTarget; + resetBlockedState(snapshot); + } + + function cancel(): void { + target = null; + blockedButtons.clear(); + blockedAxisDirections.clear(); + } + + function poll(snapshot: ControllerBindingCaptureSnapshot): ControllerBindingCaptureResult | null { + if (!target) return null; + + snapshot.buttons.forEach((button, index) => { + if (!isActiveButton(button, options.triggerDeadzone)) { + blockedButtons.delete(index); + } + }); + + const activationThreshold = Math.max(options.stickDeadzone, 0.55); + snapshot.axes.forEach((value, index) => { + const negativeKey = `${index}:negative`; + const positiveKey = `${index}:positive`; + if (getAxisDirection(value, activationThreshold) === null) { + blockedAxisDirections.delete(negativeKey); + blockedAxisDirections.delete(positiveKey); + } + }); + + // D-pad capture: only respond to d-pad buttons (12-15) + if (target.bindingType === 'dpad') { + for (const index of DPAD_BUTTON_INDICES) { + if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue; + if (blockedButtons.has(index)) continue; + + const dpadDirection: ControllerDpadFallback = + index === 12 || index === 13 ? 'vertical' : 'horizontal'; + cancel(); + return { + actionId: target.actionId, + bindingType: 'dpad' as const, + dpadDirection, + }; + } + return null; + } + + // After dpad early-return, only 'discrete' | 'axis' remain + const narrowedTarget: Extract = target; + + for (let index = 0; index < snapshot.buttons.length; index += 1) { + if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue; + if (blockedButtons.has(index)) continue; + if (narrowedTarget.bindingType === 'axis') continue; + + const result: ControllerBindingCaptureResult = { + actionId: narrowedTarget.actionId, + bindingType: 'discrete', + binding: { kind: 'button', buttonIndex: index }, + }; + cancel(); + return result; + } + + for (let index = 0; index < snapshot.axes.length; index += 1) { + const direction = getAxisDirection(snapshot.axes[index], activationThreshold); + if (!direction) continue; + const directionKey = `${index}:${direction}`; + if (blockedAxisDirections.has(directionKey)) continue; + + const result: ControllerBindingCaptureResult = + narrowedTarget.bindingType === 'discrete' + ? { + actionId: narrowedTarget.actionId, + bindingType: 'discrete', + binding: { kind: 'axis', axisIndex: index, direction }, + } + : { + actionId: narrowedTarget.actionId, + bindingType: 'axis', + binding: { + kind: 'axis', + axisIndex: index, + dpadFallback: narrowedTarget.dpadFallback, + }, + }; + cancel(); + return result; + } + + return null; + } + + return { + arm, + cancel, + isArmed: (): boolean => target !== null, + getTargetActionId: (): string | null => target?.actionId ?? null, + poll, + }; +} diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index c49edd8..c6f3641 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -13,6 +13,20 @@ type TestGamepad = { buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>; }; +const DEFAULT_BUTTON_INDICES = { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, +} satisfies ResolvedControllerConfig['buttonIndices']; + function createGamepad( id: string, options: Partial> = {}, @@ -35,7 +49,7 @@ function createGamepad( function createControllerConfig( overrides: Omit, 'bindings' | 'buttonIndices'> & { - bindings?: Partial; + bindings?: Partial>; buttonIndices?: Partial; } = {}, ): ResolvedControllerConfig { @@ -57,39 +71,92 @@ function createControllerConfig( repeatDelayMs: 320, repeatIntervalMs: 120, buttonIndices: { - select: 6, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, + ...DEFAULT_BUTTON_INDICES, ...(buttonIndexOverrides ?? {}), }, bindings: { - toggleLookup: 'buttonSouth', - closeLookup: 'buttonEast', - toggleKeyboardOnlyMode: 'buttonNorth', - mineCard: 'buttonWest', - quitMpv: 'select', - previousAudio: 'none', - nextAudio: 'rightShoulder', - playCurrentAudio: 'leftShoulder', - toggleMpvPause: 'leftStickPress', - leftStickHorizontal: 'leftStickX', - leftStickVertical: 'leftStickY', - rightStickHorizontal: 'rightStickX', - rightStickVertical: 'rightStickY', - ...(bindingOverrides ?? {}), + toggleLookup: { kind: 'button', buttonIndex: 0 }, + closeLookup: { kind: 'button', buttonIndex: 1 }, + toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 }, + mineCard: { kind: 'button', buttonIndex: 2 }, + quitMpv: { kind: 'button', buttonIndex: 6 }, + previousAudio: { kind: 'none' }, + nextAudio: { kind: 'button', buttonIndex: 5 }, + playCurrentAudio: { kind: 'button', buttonIndex: 4 }, + toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, + leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, + rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, + rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, + ...normalizeBindingOverrides(bindingOverrides ?? {}, { + ...DEFAULT_BUTTON_INDICES, + ...(buttonIndexOverrides ?? {}), + }), }, ...restOverrides, }; } +function normalizeBindingOverrides( + overrides: Partial>, + buttonIndices: ResolvedControllerConfig['buttonIndices'], +): Partial { + const legacyButtonIndices = { + select: buttonIndices.select, + buttonSouth: buttonIndices.buttonSouth, + buttonEast: buttonIndices.buttonEast, + buttonWest: buttonIndices.buttonWest, + buttonNorth: buttonIndices.buttonNorth, + leftShoulder: buttonIndices.leftShoulder, + rightShoulder: buttonIndices.rightShoulder, + leftStickPress: buttonIndices.leftStickPress, + rightStickPress: buttonIndices.rightStickPress, + leftTrigger: buttonIndices.leftTrigger, + rightTrigger: buttonIndices.rightTrigger, + } as const; + const legacyAxisIndices = { + leftStickX: 0, + leftStickY: 1, + rightStickX: 3, + rightStickY: 4, + } as const; + const axisFallbackByKey = { + leftStickHorizontal: 'horizontal', + leftStickVertical: 'vertical', + rightStickHorizontal: 'none', + rightStickVertical: 'none', + } as const; + + const normalized: Partial = {}; + for (const [key, value] of Object.entries(overrides) as Array< + [keyof ResolvedControllerConfig['bindings'], unknown] + >) { + if (typeof value === 'string') { + if (value === 'none') { + normalized[key] = { kind: 'none' } as never; + continue; + } + if (value in legacyButtonIndices) { + normalized[key] = { + kind: 'button', + buttonIndex: legacyButtonIndices[value as keyof typeof legacyButtonIndices], + } as never; + continue; + } + if (value in legacyAxisIndices) { + normalized[key] = { + kind: 'axis', + axisIndex: legacyAxisIndices[value as keyof typeof legacyAxisIndices], + dpadFallback: axisFallbackByKey[key as keyof typeof axisFallbackByKey] ?? 'none', + } as never; + continue; + } + } + normalized[key] = value as never; + } + return normalized; +} + test('gamepad controller selects the first connected controller by default', () => { const updates: string[] = []; const controller = createGamepadController({ @@ -184,6 +251,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 })); @@ -622,6 +765,46 @@ test('gamepad controller trigger digital mode uses pressed state only', () => { assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']); }); +test('gamepad controller digital trigger bindings ignore analog-only trigger values', () => { + const calls: string[] = []; + const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false })); + buttons[6] = { value: 0.9, pressed: false, touched: true }; + buttons[7] = { value: 0.9, pressed: false, touched: true }; + + const controller = createGamepadController({ + getGamepads: () => [createGamepad('pad-1', { buttons })], + getConfig: () => + createControllerConfig({ + triggerInputMode: 'digital', + triggerDeadzone: 0.6, + bindings: { + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + }, + }), + getKeyboardModeEnabled: () => true, + getLookupWindowOpen: () => true, + getInteractionBlocked: () => false, + toggleKeyboardMode: () => {}, + toggleLookup: () => {}, + closeLookup: () => {}, + moveSelection: () => {}, + mineCard: () => {}, + quitMpv: () => {}, + previousAudio: () => {}, + nextAudio: () => {}, + playCurrentAudio: () => calls.push('play-audio'), + toggleMpvPause: () => calls.push('toggle-mpv-pause'), + scrollPopup: () => {}, + jumpPopup: () => {}, + onState: () => {}, + }); + + controller.poll(0); + + assert.deepEqual(calls, []); +}); + test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => { const calls: string[] = []; const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false })); diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index 63939b1..3af32a8 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -1,10 +1,9 @@ import type { - ControllerAxisBinding, - ControllerButtonBinding, ControllerDeviceInfo, ControllerRuntimeSnapshot, - ControllerTriggerInputMode, + ResolvedControllerAxisBinding, ResolvedControllerConfig, + ResolvedControllerDiscreteBinding, } from '../../types'; type ControllerButtonState = { @@ -50,69 +49,18 @@ type HoldState = { initialFired: boolean; }; -const DEFAULT_BUTTON_INDEX_BY_BINDING: Record, number> = { - select: 8, - buttonSouth: 0, - buttonEast: 1, - buttonWest: 2, - buttonNorth: 3, - leftShoulder: 4, - rightShoulder: 5, - leftStickPress: 9, - rightStickPress: 10, - leftTrigger: 6, - rightTrigger: 7, -}; - -const AXIS_INDEX_BY_BINDING: Record = { - leftStickX: 0, - leftStickY: 1, - rightStickX: 3, - rightStickY: 4, -}; - const DPAD_BUTTON_INDEX = { up: 12, down: 13, left: 14, right: 15, } as const; + const DPAD_AXIS_INDEX = { horizontal: 6, vertical: 7, } as const; -function isTriggerBinding(binding: ControllerButtonBinding): boolean { - return binding === 'leftTrigger' || binding === 'rightTrigger'; -} - -function resolveButtonIndex( - config: ResolvedControllerConfig, - binding: ControllerButtonBinding, -): number { - if (binding === 'none') { - return -1; - } - return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding]; -} - -function normalizeButtonState( - gamepad: GamepadLike, - config: ResolvedControllerConfig, - binding: ControllerButtonBinding, - triggerInputMode: ControllerTriggerInputMode, - triggerDeadzone: number, -): boolean { - if (binding === 'none') { - return false; - } - const button = gamepad.buttons[resolveButtonIndex(config, binding)]; - if (isTriggerBinding(binding)) { - return normalizeTriggerState(button, triggerInputMode, triggerDeadzone); - } - return normalizeRawButtonState(button, triggerDeadzone); -} - function normalizeRawButtonState( button: ControllerButtonState | undefined, triggerDeadzone: number, @@ -121,23 +69,18 @@ function normalizeRawButtonState( return Boolean(button.pressed) || button.value >= triggerDeadzone; } -function normalizeTriggerState( +function resolveTriggerBindingPressed( button: ControllerButtonState | undefined, - mode: ControllerTriggerInputMode, - triggerDeadzone: number, + config: ResolvedControllerConfig, ): boolean { if (!button) return false; - if (mode === 'digital') { + if (config.triggerInputMode === 'digital') { return Boolean(button.pressed); } - if (mode === 'analog') { - return button.value >= triggerDeadzone; + if (config.triggerInputMode === 'analog') { + return button.value >= config.triggerDeadzone; } - return Boolean(button.pressed) || button.value >= triggerDeadzone; -} - -function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number { - return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0; + return normalizeRawButtonState(button, config.triggerDeadzone); } function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number { @@ -251,8 +194,57 @@ function syncHeldActionBlocked( state.initialFired = true; } +function resolveDiscreteBindingPressed( + gamepad: GamepadLike, + binding: ResolvedControllerDiscreteBinding, + config: ResolvedControllerConfig, +): boolean { + if (binding.kind === 'none') { + return false; + } + + if (binding.kind === 'button') { + const button = gamepad.buttons[binding.buttonIndex]; + const isTriggerBinding = + binding.buttonIndex === config.buttonIndices.leftTrigger || + binding.buttonIndex === config.buttonIndices.rightTrigger; + return isTriggerBinding + ? resolveTriggerBindingPressed(button, config) + : normalizeRawButtonState(button, config.triggerDeadzone); + } + + const activationThreshold = Math.max(config.stickDeadzone, 0.55); + const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex); + return binding.direction === 'positive' + ? axisValue >= activationThreshold + : axisValue <= -activationThreshold; +} + +function resolveAxisBindingValue( + gamepad: GamepadLike, + binding: ResolvedControllerAxisBinding, + triggerDeadzone: number, + activationThreshold: number, +): number { + if (binding.kind === 'none') { + return 0; + } + const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex); + if (Math.abs(axisValue) >= activationThreshold) { + return axisValue; + } + + if (binding.dpadFallback === 'horizontal') { + return resolveDpadHorizontalValue(gamepad, triggerDeadzone); + } + if (binding.dpadFallback === 'vertical') { + return resolveDpadVerticalValue(gamepad, triggerDeadzone); + } + return axisValue; +} + export function createGamepadController(options: GamepadControllerOptions) { - let previousButtons = new Map(); + let previousActions = new Map(); let selectionHold = createHoldState(); let jumpHold = createHoldState(); let activeGamepadId: string | null = null; @@ -297,16 +289,16 @@ export function createGamepadController(options: GamepadControllerOptions) { }); } - function handleButtonEdge( - binding: ControllerButtonBinding, - isPressed: boolean, + function handleActionEdge( + actionKey: string, + binding: ResolvedControllerDiscreteBinding, + activeGamepad: GamepadLike, + config: ResolvedControllerConfig, action: () => void, ): void { - if (binding === 'none') { - return; - } - const wasPressed = previousButtons.get(binding) ?? false; - previousButtons.set(binding, isPressed); + const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config); + const wasPressed = previousActions.get(actionKey) ?? false; + previousActions.set(actionKey, isPressed); if (!wasPressed && isPressed) { action(); } @@ -353,47 +345,42 @@ export function createGamepadController(options: GamepadControllerOptions) { config: ResolvedControllerConfig, now: number, ): void { - const buttonBindings = new Set([ - config.bindings.toggleKeyboardOnlyMode, - config.bindings.toggleLookup, - config.bindings.closeLookup, - config.bindings.mineCard, - config.bindings.quitMpv, - config.bindings.previousAudio, - config.bindings.nextAudio, - config.bindings.playCurrentAudio, - config.bindings.toggleMpvPause, - ]); + const discreteActions = [ + ['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode], + ['toggleLookup', config.bindings.toggleLookup], + ['closeLookup', config.bindings.closeLookup], + ['mineCard', config.bindings.mineCard], + ['quitMpv', config.bindings.quitMpv], + ['previousAudio', config.bindings.previousAudio], + ['nextAudio', config.bindings.nextAudio], + ['playCurrentAudio', config.bindings.playCurrentAudio], + ['toggleMpvPause', config.bindings.toggleMpvPause], + ] as const; - for (const binding of buttonBindings) { - if (binding === 'none') continue; - previousButtons.set( - binding, - normalizeButtonState( - activeGamepad, - config, - binding, - config.triggerInputMode, - config.triggerDeadzone, - ), - ); + for (const [actionKey, binding] of discreteActions) { + previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config)); } - const selectionValue = (() => { - const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal); - if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) { - return axisValue; - } - return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone); - })(); - syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55)); + const activationThreshold = Math.max(config.stickDeadzone, 0.55); + const selectionValue = resolveAxisBindingValue( + activeGamepad, + config.bindings.leftStickHorizontal, + config.triggerDeadzone, + activationThreshold, + ); + syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold); if (options.getLookupWindowOpen()) { syncHeldActionBlocked( jumpHold, - resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), + resolveAxisBindingValue( + activeGamepad, + config.bindings.rightStickVertical, + config.triggerDeadzone, + activationThreshold, + ), now, - Math.max(config.stickDeadzone, 0.55), + activationThreshold, ); } else { resetHeldAction(jumpHold); @@ -406,129 +393,102 @@ export function createGamepadController(options: GamepadControllerOptions) { const config = options.getConfig(); const connectedGamepads = getConnectedGamepads(); const activeGamepad = resolveActiveGamepad(connectedGamepads, config); + const previousActiveGamepadId = activeGamepadId; publishState(connectedGamepads, activeGamepad); if (!activeGamepad) { - previousButtons = new Map(); + previousActions = new Map(); resetHeldAction(selectionHold); resetHeldAction(jumpHold); lastPollAt = null; return; } - const interactionAllowed = + if (activeGamepad.id !== previousActiveGamepadId) { + previousActions = new Map(); + resetHeldAction(selectionHold); + resetHeldAction(jumpHold); + } + + let interactionAllowed = config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); if (config.enabled) { - handleButtonEdge( + handleActionEdge( + 'toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode, - normalizeButtonState( - activeGamepad, - config, - config.bindings.toggleKeyboardOnlyMode, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.toggleKeyboardMode, ); } + + interactionAllowed = + config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); + if (!interactionAllowed) { syncBlockedInteractionState(activeGamepad, config, now); return; } - handleButtonEdge( + handleActionEdge( + 'toggleLookup', config.bindings.toggleLookup, - normalizeButtonState( - activeGamepad, - config, - config.bindings.toggleLookup, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.toggleLookup, ); - handleButtonEdge( + handleActionEdge( + 'closeLookup', config.bindings.closeLookup, - normalizeButtonState( - activeGamepad, - config, - config.bindings.closeLookup, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.closeLookup, ); - handleButtonEdge( - config.bindings.mineCard, - normalizeButtonState( - activeGamepad, - config, - config.bindings.mineCard, - config.triggerInputMode, - config.triggerDeadzone, - ), - options.mineCard, - ); - handleButtonEdge( - config.bindings.quitMpv, - normalizeButtonState( - activeGamepad, - config, - config.bindings.quitMpv, - config.triggerInputMode, - config.triggerDeadzone, - ), - options.quitMpv, - ); + handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard); + handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv); + + const activationThreshold = Math.max(config.stickDeadzone, 0.55); if (options.getLookupWindowOpen()) { - handleButtonEdge( + handleActionEdge( + 'previousAudio', config.bindings.previousAudio, - normalizeButtonState( - activeGamepad, - config, - config.bindings.previousAudio, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.previousAudio, ); - handleButtonEdge( + handleActionEdge( + 'nextAudio', config.bindings.nextAudio, - normalizeButtonState( - activeGamepad, - config, - config.bindings.nextAudio, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.nextAudio, ); - handleButtonEdge( + handleActionEdge( + 'playCurrentAudio', config.bindings.playCurrentAudio, - normalizeButtonState( - activeGamepad, - config, - config.bindings.playCurrentAudio, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.playCurrentAudio, ); - const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone); - const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical); - if (elapsedMs > 0) { - if (Math.abs(primaryScroll) >= config.stickDeadzone) { - options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000); - } - if (dpadVertical !== 0) { - options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000); - } + const primaryScroll = resolveAxisBindingValue( + activeGamepad, + config.bindings.leftStickVertical, + config.triggerDeadzone, + config.stickDeadzone, + ); + if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) { + options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000); } handleJumpAxis( - resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), + resolveAxisBindingValue( + activeGamepad, + config.bindings.rightStickVertical, + config.triggerDeadzone, + activationThreshold, + ), now, config, ); @@ -536,26 +496,21 @@ export function createGamepadController(options: GamepadControllerOptions) { resetHeldAction(jumpHold); } - handleButtonEdge( + handleActionEdge( + 'toggleMpvPause', config.bindings.toggleMpvPause, - normalizeButtonState( - activeGamepad, - config, - config.bindings.toggleMpvPause, - config.triggerInputMode, - config.triggerDeadzone, - ), + activeGamepad, + config, options.toggleMpvPause, ); handleSelectionAxis( - (() => { - const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal); - if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) { - return axisValue; - } - return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone); - })(), + resolveAxisBindingValue( + activeGamepad, + config.bindings.leftStickHorizontal, + config.triggerDeadzone, + activationThreshold, + ), now, config, ); diff --git a/src/renderer/index.html b/src/renderer/index.html index e6a135c..2eae08d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -201,14 +201,16 @@