feat(controller): add inline remap modal with descriptor-based bindings (#21)

This commit is contained in:
2026-03-15 15:55:45 -07:00
committed by GitHub
parent 9eed37420e
commit 478869ff28
38 changed files with 3136 additions and 1431 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ tests/*
.agents/skills/subminer-scrum-master/* .agents/skills/subminer-scrum-master/*
!.agents/skills/subminer-scrum-master/SKILL.md !.agents/skills/subminer-scrum-master/SKILL.md
favicon.png favicon.png
.claude/*

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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.

View File

@@ -53,13 +53,13 @@
// ========================================== // ==========================================
// Controller Support // Controller Support
// Gamepad support for the visible overlay while keyboard-only mode is active. // 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. // 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. // Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ========================================== // ==========================================
"controller": { "controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false "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. "preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false "smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input. "scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
@@ -81,22 +81,64 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting. }, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "toggleLookup": {
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "buttonIndex": 0 // Raw button index captured for this discrete controller action.
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger }, // 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.
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "closeLookup": {
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "buttonIndex": 1 // Raw button index captured for this discrete controller action.
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger }, // 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.
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "toggleKeyboardOnlyMode": {
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY "buttonIndex": 3 // Raw button index captured for this discrete controller action.
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY }, // 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.
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY "mineCard": {
} // Bindings setting. "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. }, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ========================================== // ==========================================

View File

@@ -11,7 +11,7 @@
- Added Chrome Gamepad API controller support for keyboard-only overlay mode. - 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 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 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. - 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. - 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. - Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.

View File

@@ -514,8 +514,10 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled. - Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller. - Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected 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. - `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
- Turning keyboard-only mode off clears the keyboard-only token highlight state. - Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. - Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -547,19 +549,19 @@ Important behavior:
"rightTrigger": 7 "rightTrigger": 7
}, },
"bindings": { "bindings": {
"toggleLookup": "buttonSouth", "toggleLookup": { "kind": "button", "buttonIndex": 0 },
"closeLookup": "buttonEast", "closeLookup": { "kind": "button", "buttonIndex": 1 },
"toggleKeyboardOnlyMode": "buttonNorth", "toggleKeyboardOnlyMode": { "kind": "button", "buttonIndex": 3 },
"mineCard": "buttonWest", "mineCard": { "kind": "button", "buttonIndex": 2 },
"quitMpv": "select", "quitMpv": { "kind": "button", "buttonIndex": 6 },
"previousAudio": "none", "previousAudio": { "kind": "none" },
"nextAudio": "rightShoulder", "nextAudio": { "kind": "button", "buttonIndex": 5 },
"playCurrentAudio": "leftShoulder", "playCurrentAudio": { "kind": "button", "buttonIndex": 4 },
"toggleMpvPause": "leftStickPress", "toggleMpvPause": { "kind": "button", "buttonIndex": 9 },
"leftStickHorizontal": "leftStickX", "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
"leftStickVertical": "leftStickY", "leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
"rightStickHorizontal": "rightStickX", "rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": "rightStickY" "rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" }
} }
} }
} }
@@ -581,10 +583,28 @@ Default logical mapping:
- `L3`: toggle mpv pause - `L3`: toggle mpv pause
- `L2` / `R2`: unbound by default - `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 you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal. If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field. Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts ### Manual Card Update Shortcuts

View File

@@ -53,13 +53,13 @@
// ========================================== // ==========================================
// Controller Support // Controller Support
// Gamepad support for the visible overlay while keyboard-only mode is active. // 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. // 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. // Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ========================================== // ==========================================
"controller": { "controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false "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. "preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false "smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input. "scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
@@ -81,22 +81,64 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting. }, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "toggleLookup": {
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "buttonIndex": 0 // Raw button index captured for this discrete controller action.
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger }, // 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.
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "closeLookup": {
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "buttonIndex": 1 // Raw button index captured for this discrete controller action.
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger }, // 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.
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger "toggleKeyboardOnlyMode": {
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY "buttonIndex": 3 // Raw button index captured for this discrete controller action.
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY }, // 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.
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY "mineCard": {
} // Bindings setting. "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. }, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ========================================== // ==========================================

View File

@@ -75,7 +75,7 @@ These overlay-local shortcuts are fixed and open controller utilities for the Ch
| Shortcut | Action | Configurable | | Shortcut | Action | Configurable |
| ------------- | ------------------------------ | ------------ | | ------------- | ------------------------------ | ------------ |
| `Alt+C` | Open controller selection modal | Fixed | | `Alt+C` | Open controller config + remap modal | Fixed |
| `Alt+Shift+C` | Open controller debug 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. 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.

View File

@@ -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. 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. 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. 3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline.
4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. 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 ### Default Button Mapping
@@ -278,10 +280,11 @@ By default SubMiner uses the first connected controller. Press `Alt+C` in the ov
| Input | Action | | Input | Action |
| ----- | ------ | | ----- | ------ |
| Left stick horizontal | Move token selection left/right | | Left stick horizontal | Move token selection left/right |
| Left stick vertical | Smooth scroll Yomitan popup | | Left stick vertical | Scroll Yomitan popup |
| Right stick horizontal | Jump inside popup (horizontal) | | Right stick vertical | Jump through Yomitan popup |
| Right stick vertical | Smooth scroll popup (vertical) | | D-pad | Fallback for stick navigation when configured |
| D-pad | Fallback for stick navigation |
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. All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.

View File

@@ -1168,12 +1168,103 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
assert.equal(config.controller.repeatIntervalMs, 70); assert.equal(config.controller.repeatIntervalMs, 70);
assert.equal(config.controller.buttonIndices.select, 6); assert.equal(config.controller.buttonIndices.select, 6);
assert.equal(config.controller.buttonIndices.leftStickPress, 9); assert.equal(config.controller.buttonIndices.leftStickPress, 9);
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest'); assert.deepEqual(config.controller.bindings.toggleLookup, { kind: 'button', buttonIndex: 2 });
assert.equal(config.controller.bindings.quitMpv, 'select'); assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
assert.equal(config.controller.bindings.playCurrentAudio, 'none'); assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress'); assert.deepEqual(config.controller.bindings.toggleMpvPause, { kind: 'button', buttonIndex: 9 });
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX'); assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY'); 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', () => { test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
@@ -1825,6 +1916,24 @@ test('template generator includes known keys', () => {
output, output,
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/, /"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, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match( assert.match(
output, output,

View File

@@ -58,19 +58,19 @@ export const CORE_DEFAULT_CONFIG: Pick<
rightTrigger: 7, rightTrigger: 7,
}, },
bindings: { bindings: {
toggleLookup: 'buttonSouth', toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: 'buttonEast', closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: 'buttonNorth', toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: 'buttonWest', mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: 'select', quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: 'none', previousAudio: { kind: 'none' },
nextAudio: 'rightShoulder', nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: 'leftShoulder', playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: 'leftStickPress', toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: 'leftStickX', leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: 'leftStickY', leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: 'rightStickX', rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: 'rightStickY', rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}, },
}, },
shortcuts: { shortcuts: {

View File

@@ -4,20 +4,76 @@ import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry( export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig, defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] { ): ConfigOptionRegistryEntry[] {
const controllerButtonEnumValues = [ const discreteBindings = [
'none', {
'select', id: 'toggleLookup',
'buttonSouth', defaultValue: defaultConfig.controller.bindings.toggleLookup,
'buttonEast', description: 'Controller binding descriptor for toggling lookup.',
'buttonNorth', },
'buttonWest', {
'leftShoulder', id: 'closeLookup',
'rightShoulder', defaultValue: defaultConfig.controller.bindings.closeLookup,
'leftStickPress', description: 'Controller binding descriptor for closing lookup.',
'rightStickPress', },
'leftTrigger', {
'rightTrigger', 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 [ return [
{ {
@@ -37,7 +93,7 @@ export function buildCoreConfigOptionRegistry(
path: 'controller.preferredGamepadId', path: 'controller.preferredGamepadId',
kind: 'string', kind: 'string',
defaultValue: defaultConfig.controller.preferredGamepadId, 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', path: 'controller.preferredGamepadLabel',
@@ -96,6 +152,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.repeatIntervalMs, defaultValue: defaultConfig.controller.repeatIntervalMs,
description: 'Repeat interval for held controller actions.', description: 'Repeat interval for held controller actions.',
}, },
{
path: 'controller.buttonIndices',
kind: 'object',
defaultValue: defaultConfig.controller.buttonIndices,
description:
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.',
},
{ {
path: 'controller.buttonIndices.select', path: 'controller.buttonIndices.select',
kind: 'number', kind: 'number',
@@ -163,96 +226,79 @@ export function buildCoreConfigOptionRegistry(
description: 'Raw button index used for controller R2 input.', description: 'Raw button index used for controller R2 input.',
}, },
{ {
path: 'controller.bindings.toggleLookup', path: 'controller.bindings',
kind: 'enum', kind: 'object',
enumValues: controllerButtonEnumValues, defaultValue: defaultConfig.controller.bindings,
defaultValue: defaultConfig.controller.bindings.toggleLookup, description:
description: 'Controller binding for toggling lookup.', '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.closeLookup', path: `controller.bindings.${binding.id}.kind`,
kind: 'enum', kind: 'enum' as const,
enumValues: controllerButtonEnumValues, enumValues: ['none', 'button', 'axis'],
defaultValue: defaultConfig.controller.bindings.closeLookup, defaultValue: binding.defaultValue.kind,
description: 'Controller binding for closing lookup.', description:
'Discrete binding input source kind. When kind is "axis", set both axisIndex and direction.',
}, },
{ {
path: 'controller.bindings.toggleKeyboardOnlyMode', path: `controller.bindings.${binding.id}.buttonIndex`,
kind: 'enum', kind: 'number' as const,
enumValues: controllerButtonEnumValues, defaultValue:
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode, binding.defaultValue.kind === 'button' ? binding.defaultValue.buttonIndex : undefined,
description: 'Controller binding for toggling keyboard-only mode.', description: 'Raw button index captured for this discrete controller action.',
}, },
{ {
path: 'controller.bindings.mineCard', path: `controller.bindings.${binding.id}.axisIndex`,
kind: 'enum', kind: 'number' as const,
enumValues: controllerButtonEnumValues, defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
defaultValue: defaultConfig.controller.bindings.mineCard, description: 'Raw axis index captured for this discrete controller action.',
description: 'Controller binding for mining the active card.',
}, },
{ {
path: 'controller.bindings.quitMpv', path: `controller.bindings.${binding.id}.direction`,
kind: 'enum', kind: 'enum' as const,
enumValues: controllerButtonEnumValues, enumValues: ['negative', 'positive'],
defaultValue: defaultConfig.controller.bindings.quitMpv, defaultValue:
description: 'Controller binding for quitting mpv.', 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.previousAudio', path: `controller.bindings.${binding.id}.kind`,
kind: 'enum', kind: 'enum' as const,
enumValues: controllerButtonEnumValues, enumValues: ['none', 'axis'],
defaultValue: defaultConfig.controller.bindings.previousAudio, defaultValue: binding.defaultValue.kind,
description: 'Controller binding for previous Yomitan audio.', description: 'Analog binding input source kind.',
}, },
{ {
path: 'controller.bindings.nextAudio', path: `controller.bindings.${binding.id}.axisIndex`,
kind: 'enum', kind: 'number' as const,
enumValues: controllerButtonEnumValues, defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
defaultValue: defaultConfig.controller.bindings.nextAudio, description: 'Raw axis index captured for this analog controller action.',
description: 'Controller binding for next Yomitan audio.',
}, },
{ {
path: 'controller.bindings.playCurrentAudio', path: `controller.bindings.${binding.id}.dpadFallback`,
kind: 'enum', kind: 'enum' as const,
enumValues: controllerButtonEnumValues, enumValues: ['none', 'horizontal', 'vertical'],
defaultValue: defaultConfig.controller.bindings.playCurrentAudio, defaultValue:
description: 'Controller binding for playing the current Yomitan audio.', 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: '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: 'texthooker.launchAtStartup', path: 'texthooker.launchAtStartup',
kind: 'boolean', kind: 'boolean',

View File

@@ -38,7 +38,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'Controller Support', title: 'Controller Support',
description: [ description: [
'Gamepad support for the visible overlay while keyboard-only mode is active.', '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.', '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.', 'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
], ],

View File

@@ -1,9 +1,18 @@
import type {
ControllerAxisBinding,
ControllerAxisBindingConfig,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ControllerDiscreteBindingConfig,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types';
import { ResolveContext } from './context'; import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared'; import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void { const CONTROLLER_BUTTON_BINDINGS = [
const { src, resolved, warn } = context;
const controllerButtonBindings = [
'none', 'none',
'select', 'select',
'buttonSouth', 'buttonSouth',
@@ -17,12 +26,116 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'leftTrigger', 'leftTrigger',
'rightTrigger', 'rightTrigger',
] as const; ] as const;
const controllerAxisBindings = [
'leftStickX', const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
'leftStickY',
'rightStickX', const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
'rightStickY', leftStickX: 0,
] as const; leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
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<string, ControllerDpadFallback>;
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<ControllerButtonIndicesConfig>,
): 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;
if (isObject(src.texthooker)) { if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
@@ -251,19 +364,27 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
] as const; ] as const;
for (const key of buttonBindingKeys) { for (const key of buttonBindingKeys) {
const value = asString(src.controller.bindings[key]); const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if ( if (
value !== undefined && legacyValue !== undefined &&
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number]) CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number])
) { ) {
resolved.controller.bindings[key] = resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
value as (typeof resolved.controller.bindings)[typeof key]; legacyValue as ControllerButtonBinding,
} else if (src.controller.bindings[key] !== undefined) { resolved.controller.buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn( warn(
`controller.bindings.${key}`, `controller.bindings.${key}`,
src.controller.bindings[key], bindingValue,
resolved.controller.bindings[key], 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; ] as const;
for (const key of axisBindingKeys) { for (const key of axisBindingKeys) {
const value = asString(src.controller.bindings[key]); const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if ( if (
value !== undefined && legacyValue !== undefined &&
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number]) CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
) { ) {
resolved.controller.bindings[key] = resolved.controller.bindings[key] = resolveLegacyAxisBinding(
value as (typeof resolved.controller.bindings)[typeof key]; legacyValue as ControllerAxisBinding,
} else if (src.controller.bindings[key] !== undefined) { 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( warn(
`controller.bindings.${key}`, `controller.bindings.${key}`,
src.controller.bindings[key], bindingValue,
resolved.controller.bindings[key], resolved.controller.bindings[key],
`Expected one of: ${controllerAxisBindings.join(', ')}.`, "Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
); );
} }
} }

View File

@@ -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 () => { test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createIpcDepsRuntime({ const deps = createIpcDepsRuntime({
@@ -53,47 +97,8 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({ getControllerConfig: () => createControllerConfigFixture(),
enabled: true, saveControllerConfig: () => {},
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',
},
}),
saveControllerPreference: () => {}, saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover', getSecondarySubMode: () => 'hover',
getMpvClient: () => null, getMpvClient: () => null,
@@ -159,47 +164,8 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({ getControllerConfig: () => createControllerConfigFixture(),
enabled: true, saveControllerConfig: () => {},
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',
},
}),
saveControllerPreference: () => {}, saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover', getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '', getCurrentSecondarySub: () => '',
@@ -299,47 +265,10 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({ getControllerConfig: () => createControllerConfigFixture(),
enabled: true, saveControllerConfig: (update) => {
preferredGamepadId: '', controllerSaves.push(update);
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',
},
}),
saveControllerPreference: (update) => { saveControllerPreference: (update) => {
controllerSaves.push(update); controllerSaves.push(update);
}, },
@@ -400,47 +329,8 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({ getControllerConfig: () => createControllerConfigFixture(),
enabled: true, saveControllerConfig: async () => {},
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',
},
}),
saveControllerPreference: async (update) => { saveControllerPreference: async (update) => {
await Promise.resolve(); await Promise.resolve();
controllerSaves.push(update); 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 () => { test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers( registerIpcHandlers(
@@ -508,47 +477,8 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({ getControllerConfig: () => createControllerConfigFixture(),
enabled: true, saveControllerConfig: async () => {},
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',
},
}),
saveControllerPreference: async () => {}, saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover', getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '', getCurrentSecondarySub: () => '',

View File

@@ -1,6 +1,7 @@
import electron from 'electron'; import electron from 'electron';
import type { IpcMainEvent } from 'electron'; import type { IpcMainEvent } from 'electron';
import type { import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
RuntimeOptionId, RuntimeOptionId,
@@ -12,6 +13,7 @@ import type {
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import { import {
parseMpvCommand, parseMpvCommand,
parseControllerConfigUpdate,
parseControllerPreferenceUpdate, parseControllerPreferenceUpdate,
parseOptionalForwardingOptions, parseOptionalForwardingOptions,
parseOverlayHostedModal, parseOverlayHostedModal,
@@ -49,6 +51,7 @@ export interface IpcServiceDeps {
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string; getCurrentSecondarySub: () => string;
@@ -114,6 +117,7 @@ export interface IpcDepsRuntimeOptions {
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
@@ -167,6 +171,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getKeybindings: options.getKeybindings, getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts, getConfiguredShortcuts: options.getConfiguredShortcuts,
getControllerConfig: options.getControllerConfig, getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig,
saveControllerPreference: options.saveControllerPreference, saveControllerPreference: options.saveControllerPreference,
getSecondarySubMode: options.getSecondarySubMode, getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '', 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, () => { ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus(); return deps.getMecabStatus();
}); });

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
getMpvClient: IpcDepsRuntimeOptions['getMpvClient']; getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
@@ -216,6 +217,7 @@ export function createMainIpcRuntimeServiceDeps(
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts, getConfiguredShortcuts: params.getConfiguredShortcuts,
getControllerConfig: params.getControllerConfig, getControllerConfig: params.getControllerConfig,
saveControllerConfig: params.saveControllerConfig,
saveControllerPreference: params.saveControllerPreference, saveControllerPreference: params.saveControllerPreference,
focusMainWindow: params.focusMainWindow ?? (() => {}), focusMainWindow: params.focusMainWindow ?? (() => {}),
getSecondarySubMode: params.getSecondarySubMode, getSecondarySubMode: params.getSecondarySubMode,

View File

@@ -52,6 +52,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}) as never, getConfiguredShortcuts: () => ({}) as never,
getControllerConfig: () => ({}) as never, getControllerConfig: () => ({}) as never,
saveControllerConfig: () => {},
saveControllerPreference: () => {}, saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover' as never, getSecondarySubMode: () => 'hover' as never,
getMpvClient: () => null, getMpvClient: () => null,

View File

@@ -48,6 +48,7 @@ import type {
OverlayContentMeasurement, OverlayContentMeasurement,
ShortcutsConfig, ShortcutsConfig,
ConfigHotReloadPayload, ConfigHotReloadPayload,
ControllerConfigUpdate,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
} from './types'; } from './types';
@@ -209,6 +210,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
getControllerConfig: (): Promise<ResolvedControllerConfig> => getControllerConfig: (): Promise<ResolvedControllerConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerConfig: (update: ControllerConfigUpdate): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update),
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> => saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update), ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),

View File

@@ -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' },
});
});

View File

@@ -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<number>();
const blockedAxisDirections = new Set<string>();
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<ControllerBindingCaptureTarget, { bindingType: 'discrete' | 'axis' }> = 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,
};
}

View File

@@ -13,6 +13,20 @@ type TestGamepad = {
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>; 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( function createGamepad(
id: string, id: string,
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {}, options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
@@ -35,7 +49,7 @@ function createGamepad(
function createControllerConfig( function createControllerConfig(
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & { overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
bindings?: Partial<ResolvedControllerConfig['bindings']>; bindings?: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>;
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>; buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
} = {}, } = {},
): ResolvedControllerConfig { ): ResolvedControllerConfig {
@@ -57,39 +71,92 @@ function createControllerConfig(
repeatDelayMs: 320, repeatDelayMs: 320,
repeatIntervalMs: 120, repeatIntervalMs: 120,
buttonIndices: { buttonIndices: {
select: 6, ...DEFAULT_BUTTON_INDICES,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
...(buttonIndexOverrides ?? {}), ...(buttonIndexOverrides ?? {}),
}, },
bindings: { bindings: {
toggleLookup: 'buttonSouth', toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: 'buttonEast', closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: 'buttonNorth', toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: 'buttonWest', mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: 'select', quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: 'none', previousAudio: { kind: 'none' },
nextAudio: 'rightShoulder', nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: 'leftShoulder', playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: 'leftStickPress', toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: 'leftStickX', leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: 'leftStickY', leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: 'rightStickX', rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: 'rightStickY', rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
...(bindingOverrides ?? {}), ...normalizeBindingOverrides(bindingOverrides ?? {}, {
...DEFAULT_BUTTON_INDICES,
...(buttonIndexOverrides ?? {}),
}),
}, },
...restOverrides, ...restOverrides,
}; };
} }
function normalizeBindingOverrides(
overrides: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>,
buttonIndices: ResolvedControllerConfig['buttonIndices'],
): Partial<ResolvedControllerConfig['bindings']> {
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<ResolvedControllerConfig['bindings']> = {};
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', () => { test('gamepad controller selects the first connected controller by default', () => {
const updates: string[] = []; const updates: string[] = [];
const controller = createGamepadController({ 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']); assert.deepEqual(calls, ['toggle-keyboard-mode']);
}); });
test('gamepad controller re-evaluates interaction gating after toggling keyboard mode', () => {
const calls: string[] = [];
let keyboardModeEnabled = true;
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => keyboardModeEnabled,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {
calls.push('toggle-keyboard-mode');
keyboardModeEnabled = false;
},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller resets edge state when active controller changes', () => {
const calls: string[] = [];
let currentGamepads = [
createGamepad('pad-1', {
buttons: [{ value: 1, pressed: true, touched: true }],
}),
];
const controller = createGamepadController({
getGamepads: () => currentGamepads,
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
currentGamepads = [
createGamepad('pad-2', {
buttons: [{ value: 1, pressed: true, touched: true }],
}),
];
controller.poll(50);
assert.deepEqual(calls, ['toggle-lookup', 'toggle-lookup']);
});
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => { test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = []; const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false })); const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
@@ -622,6 +765,46 @@ test('gamepad controller trigger digital mode uses pressed state only', () => {
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']); 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', () => { test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
const calls: string[] = []; const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false })); const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));

View File

@@ -1,10 +1,9 @@
import type { import type {
ControllerAxisBinding,
ControllerButtonBinding,
ControllerDeviceInfo, ControllerDeviceInfo,
ControllerRuntimeSnapshot, ControllerRuntimeSnapshot,
ControllerTriggerInputMode, ResolvedControllerAxisBinding,
ResolvedControllerConfig, ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types'; } from '../../types';
type ControllerButtonState = { type ControllerButtonState = {
@@ -50,69 +49,18 @@ type HoldState = {
initialFired: boolean; initialFired: boolean;
}; };
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, 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<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const DPAD_BUTTON_INDEX = { const DPAD_BUTTON_INDEX = {
up: 12, up: 12,
down: 13, down: 13,
left: 14, left: 14,
right: 15, right: 15,
} as const; } as const;
const DPAD_AXIS_INDEX = { const DPAD_AXIS_INDEX = {
horizontal: 6, horizontal: 6,
vertical: 7, vertical: 7,
} as const; } 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( function normalizeRawButtonState(
button: ControllerButtonState | undefined, button: ControllerButtonState | undefined,
triggerDeadzone: number, triggerDeadzone: number,
@@ -121,23 +69,18 @@ function normalizeRawButtonState(
return Boolean(button.pressed) || button.value >= triggerDeadzone; return Boolean(button.pressed) || button.value >= triggerDeadzone;
} }
function normalizeTriggerState( function resolveTriggerBindingPressed(
button: ControllerButtonState | undefined, button: ControllerButtonState | undefined,
mode: ControllerTriggerInputMode, config: ResolvedControllerConfig,
triggerDeadzone: number,
): boolean { ): boolean {
if (!button) return false; if (!button) return false;
if (mode === 'digital') { if (config.triggerInputMode === 'digital') {
return Boolean(button.pressed); return Boolean(button.pressed);
} }
if (mode === 'analog') { if (config.triggerInputMode === 'analog') {
return button.value >= triggerDeadzone; return button.value >= config.triggerDeadzone;
} }
return Boolean(button.pressed) || button.value >= triggerDeadzone; return normalizeRawButtonState(button, config.triggerDeadzone);
}
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
} }
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number { function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
@@ -251,8 +194,57 @@ function syncHeldActionBlocked(
state.initialFired = true; 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) { export function createGamepadController(options: GamepadControllerOptions) {
let previousButtons = new Map<ControllerButtonBinding, boolean>(); let previousActions = new Map<string, boolean>();
let selectionHold = createHoldState(); let selectionHold = createHoldState();
let jumpHold = createHoldState(); let jumpHold = createHoldState();
let activeGamepadId: string | null = null; let activeGamepadId: string | null = null;
@@ -297,16 +289,16 @@ export function createGamepadController(options: GamepadControllerOptions) {
}); });
} }
function handleButtonEdge( function handleActionEdge(
binding: ControllerButtonBinding, actionKey: string,
isPressed: boolean, binding: ResolvedControllerDiscreteBinding,
activeGamepad: GamepadLike,
config: ResolvedControllerConfig,
action: () => void, action: () => void,
): void { ): void {
if (binding === 'none') { const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
return; const wasPressed = previousActions.get(actionKey) ?? false;
} previousActions.set(actionKey, isPressed);
const wasPressed = previousButtons.get(binding) ?? false;
previousButtons.set(binding, isPressed);
if (!wasPressed && isPressed) { if (!wasPressed && isPressed) {
action(); action();
} }
@@ -353,47 +345,42 @@ export function createGamepadController(options: GamepadControllerOptions) {
config: ResolvedControllerConfig, config: ResolvedControllerConfig,
now: number, now: number,
): void { ): void {
const buttonBindings = new Set<ControllerButtonBinding>([ const discreteActions = [
config.bindings.toggleKeyboardOnlyMode, ['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode],
config.bindings.toggleLookup, ['toggleLookup', config.bindings.toggleLookup],
config.bindings.closeLookup, ['closeLookup', config.bindings.closeLookup],
config.bindings.mineCard, ['mineCard', config.bindings.mineCard],
config.bindings.quitMpv, ['quitMpv', config.bindings.quitMpv],
config.bindings.previousAudio, ['previousAudio', config.bindings.previousAudio],
config.bindings.nextAudio, ['nextAudio', config.bindings.nextAudio],
config.bindings.playCurrentAudio, ['playCurrentAudio', config.bindings.playCurrentAudio],
config.bindings.toggleMpvPause, ['toggleMpvPause', config.bindings.toggleMpvPause],
]); ] as const;
for (const binding of buttonBindings) { for (const [actionKey, binding] of discreteActions) {
if (binding === 'none') continue; previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
previousButtons.set( }
binding,
normalizeButtonState( const activationThreshold = Math.max(config.stickDeadzone, 0.55);
const selectionValue = resolveAxisBindingValue(
activeGamepad, activeGamepad,
config, config.bindings.leftStickHorizontal,
binding,
config.triggerInputMode,
config.triggerDeadzone, config.triggerDeadzone,
), activationThreshold,
); );
} syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold);
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));
if (options.getLookupWindowOpen()) { if (options.getLookupWindowOpen()) {
syncHeldActionBlocked( syncHeldActionBlocked(
jumpHold, jumpHold,
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), resolveAxisBindingValue(
activeGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
),
now, now,
Math.max(config.stickDeadzone, 0.55), activationThreshold,
); );
} else { } else {
resetHeldAction(jumpHold); resetHeldAction(jumpHold);
@@ -406,129 +393,102 @@ export function createGamepadController(options: GamepadControllerOptions) {
const config = options.getConfig(); const config = options.getConfig();
const connectedGamepads = getConnectedGamepads(); const connectedGamepads = getConnectedGamepads();
const activeGamepad = resolveActiveGamepad(connectedGamepads, config); const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
const previousActiveGamepadId = activeGamepadId;
publishState(connectedGamepads, activeGamepad); publishState(connectedGamepads, activeGamepad);
if (!activeGamepad) { if (!activeGamepad) {
previousButtons = new Map(); previousActions = new Map();
resetHeldAction(selectionHold); resetHeldAction(selectionHold);
resetHeldAction(jumpHold); resetHeldAction(jumpHold);
lastPollAt = null; lastPollAt = null;
return; return;
} }
const interactionAllowed = if (activeGamepad.id !== previousActiveGamepadId) {
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
}
let interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (config.enabled) { if (config.enabled) {
handleButtonEdge( handleActionEdge(
'toggleKeyboardOnlyMode',
config.bindings.toggleKeyboardOnlyMode, config.bindings.toggleKeyboardOnlyMode,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.toggleKeyboardOnlyMode,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleKeyboardMode, options.toggleKeyboardMode,
); );
} }
interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (!interactionAllowed) { if (!interactionAllowed) {
syncBlockedInteractionState(activeGamepad, config, now); syncBlockedInteractionState(activeGamepad, config, now);
return; return;
} }
handleButtonEdge( handleActionEdge(
'toggleLookup',
config.bindings.toggleLookup, config.bindings.toggleLookup,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.toggleLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleLookup, options.toggleLookup,
); );
handleButtonEdge( handleActionEdge(
'closeLookup',
config.bindings.closeLookup, config.bindings.closeLookup,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.closeLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
options.closeLookup, options.closeLookup,
); );
handleButtonEdge( handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
config.bindings.mineCard, handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
normalizeButtonState(
activeGamepad, const activationThreshold = Math.max(config.stickDeadzone, 0.55);
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,
);
if (options.getLookupWindowOpen()) { if (options.getLookupWindowOpen()) {
handleButtonEdge( handleActionEdge(
'previousAudio',
config.bindings.previousAudio, config.bindings.previousAudio,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.previousAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.previousAudio, options.previousAudio,
); );
handleButtonEdge( handleActionEdge(
'nextAudio',
config.bindings.nextAudio, config.bindings.nextAudio,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.nextAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.nextAudio, options.nextAudio,
); );
handleButtonEdge( handleActionEdge(
'playCurrentAudio',
config.bindings.playCurrentAudio, config.bindings.playCurrentAudio,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.playCurrentAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.playCurrentAudio, options.playCurrentAudio,
); );
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone); const primaryScroll = resolveAxisBindingValue(
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical); activeGamepad,
if (elapsedMs > 0) { config.bindings.leftStickVertical,
if (Math.abs(primaryScroll) >= config.stickDeadzone) { config.triggerDeadzone,
config.stickDeadzone,
);
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000); options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
} }
if (dpadVertical !== 0) {
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
}
handleJumpAxis( handleJumpAxis(
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), resolveAxisBindingValue(
activeGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
),
now, now,
config, config,
); );
@@ -536,26 +496,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
resetHeldAction(jumpHold); resetHeldAction(jumpHold);
} }
handleButtonEdge( handleActionEdge(
'toggleMpvPause',
config.bindings.toggleMpvPause, config.bindings.toggleMpvPause,
normalizeButtonState(
activeGamepad, activeGamepad,
config, config,
config.bindings.toggleMpvPause,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleMpvPause, options.toggleMpvPause,
); );
handleSelectionAxis( handleSelectionAxis(
(() => { resolveAxisBindingValue(
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal); activeGamepad,
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) { config.bindings.leftStickHorizontal,
return axisValue; config.triggerDeadzone,
} activationThreshold,
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone); ),
})(),
now, now,
config, config,
); );

View File

@@ -201,14 +201,16 @@
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true"> <div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content"> <div class="modal-content runtime-modal-content">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title">Controller Selection</div> <div class="modal-title">Controller Configuration</div>
<button id="controllerSelectClose" class="modal-close" type="button">Close</button> <button id="controllerSelectClose" class="modal-close" type="button">Close</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="controllerSelectHint" class="runtime-options-hint"> <label class="controller-select-field">
Arrow keys: select controller · Enter: save · Esc: close <span>Preferred Controller</span>
</div> <select id="controllerSelectPicker"></select>
<ul id="controllerSelectList" class="runtime-options-list"></ul> </label>
<div id="controllerSelectSummary" class="controller-select-summary"></div>
<div id="controllerConfigList" class="controller-config-list"></div>
<div id="controllerSelectStatus" class="runtime-options-status"></div> <div id="controllerSelectStatus" class="runtime-options-status"></div>
<div class="subsync-footer"> <div class="subsync-footer">
<button id="controllerSelectSave" class="kiku-confirm-button" type="button"> <button id="controllerSelectSave" class="kiku-confirm-button" type="button">

View File

@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerConfigForm } from './controller-config-form.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return tokens.has(entry);
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
function createFakeElement() {
const attributes = new Map<string, string>();
const el = {
className: '',
textContent: '',
_innerHTML: '',
value: '',
disabled: false,
selected: false,
type: '',
children: [] as any[],
listeners: new Map<string, Array<(e?: any) => void>>(),
classList: createClassList(),
appendChild(child: any) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (e?: any) => void) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
},
dispatch(type: string) {
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
for (const listener of this.listeners.get(type) ?? []) {
listener(fakeEvent);
}
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
};
Object.defineProperty(el, 'innerHTML', {
get() {
return el._innerHTML;
},
set(v: string) {
el._innerHTML = v;
if (v === '') el.children.length = 0;
},
});
return el;
}
test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
});
try {
const calls: string[] = [];
const container = createFakeElement();
const form = createControllerConfigForm({
container: container as never,
getBindings: () =>
({
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' },
}) as never,
getLearningActionId: () => 'toggleLookup',
getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
onClear: (actionId) => calls.push(`clear:${actionId}`),
onReset: (actionId) => calls.push(`reset:${actionId}`),
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
});
form.render();
// In the new compact list layout, children are:
// [0] group header, [1] first binding row (auto-expanded because learning), [2] edit panel, [3] next row, ...
const firstRow = container.children[1];
assert.equal(firstRow.classList.contains('expanded'), true);
// After expanding, the edit panel is inserted after the row:
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
const editPanel = container.children[2];
// editPanel > inner > actions > learnButton
const inner = editPanel.children[0];
const actions = inner.children[1];
const learnButton = actions.children[0];
learnButton.dispatch('click');
actions.children[1].dispatch('click');
actions.children[2].dispatch('click');
assert.deepEqual(calls, [
'learn:toggleLookup:discrete',
'clear:toggleLookup',
'reset:toggleLookup',
]);
} finally {
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
});

View File

@@ -0,0 +1,429 @@
import type {
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types';
type ControllerBindingActionId = keyof ResolvedControllerConfig['bindings'];
type ControllerBindingDefinition = {
id: ControllerBindingActionId;
label: string;
group: string;
bindingType: 'discrete' | 'axis';
defaultBinding: ResolvedControllerConfig['bindings'][ControllerBindingActionId];
};
export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
{
id: 'toggleLookup',
label: 'Toggle Lookup',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 0 },
},
{
id: 'closeLookup',
label: 'Close Lookup',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 1 },
},
{
id: 'mineCard',
label: 'Mine Card',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 2 },
},
{
id: 'toggleKeyboardOnlyMode',
label: 'Toggle Keyboard-Only Mode',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 3 },
},
{
id: 'toggleMpvPause',
label: 'Toggle MPV Pause',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 9 },
},
{
id: 'quitMpv',
label: 'Quit MPV',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 6 },
},
{
id: 'previousAudio',
label: 'Previous Audio',
group: 'Popup Audio',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'nextAudio',
label: 'Next Audio',
group: 'Popup Audio',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 5 },
},
{
id: 'playCurrentAudio',
label: 'Play Current Audio',
group: 'Popup Audio',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 4 },
},
{
id: 'leftStickHorizontal',
label: 'Token Move',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
},
{
id: 'leftStickVertical',
label: 'Popup Scroll',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
},
{
id: 'rightStickHorizontal',
label: 'Alt Horizontal',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
},
{
id: 'rightStickVertical',
label: 'Popup Jump',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
];
export function getControllerBindingDefinition(actionId: ControllerBindingActionId) {
return CONTROLLER_BINDING_DEFINITIONS.find((definition) => definition.id === actionId) ?? null;
}
export function getDefaultControllerBinding(actionId: ControllerBindingActionId) {
const definition = getControllerBindingDefinition(actionId);
if (!definition) {
return { kind: 'none' } as const;
}
return JSON.parse(JSON.stringify(definition.defaultBinding)) as ResolvedControllerConfig['bindings'][ControllerBindingActionId];
}
export function getDefaultDpadFallback(actionId: ControllerBindingActionId): ControllerDpadFallback {
const definition = getControllerBindingDefinition(actionId);
if (!definition || definition.defaultBinding.kind !== 'axis') return 'none';
const binding = definition.defaultBinding;
return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none';
}
const STANDARD_BUTTON_NAMES: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
3: 'Y / Triangle',
4: 'LB / L1',
5: 'RB / R1',
6: 'Back / Select',
7: 'Start / Options',
8: 'L3 / LS',
9: 'R3 / RS',
10: 'Left Stick Click',
11: 'Right Stick Click',
12: 'D-pad Up',
13: 'D-pad Down',
14: 'D-pad Left',
15: 'D-pad Right',
16: 'Guide / Home',
};
const STANDARD_AXIS_NAMES: Record<number, string> = {
0: 'Left Stick X',
1: 'Left Stick Y',
2: 'Left Trigger',
3: 'Right Stick X',
4: 'Right Stick Y',
5: 'Right Trigger',
};
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
none: 'None',
horizontal: 'D-pad \u2194',
vertical: 'D-pad \u2195',
};
function getFriendlyButtonName(buttonIndex: number): string {
return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`;
}
function getFriendlyAxisName(axisIndex: number): string {
return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`;
}
export function formatControllerBindingSummary(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
): string {
if (binding.kind === 'none') {
return 'Disabled';
}
if ('direction' in binding) {
return `Axis ${binding.axisIndex} ${binding.direction === 'positive' ? '+' : '-'}`;
}
if ('buttonIndex' in binding) {
return `Button ${binding.buttonIndex}`;
}
if (binding.dpadFallback === 'none') {
return `Axis ${binding.axisIndex}`;
}
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
}
function formatFriendlyStickLabel(binding: ResolvedControllerAxisBinding): string {
if (binding.kind === 'none') return 'None';
return getFriendlyAxisName(binding.axisIndex);
}
function formatFriendlyBindingLabel(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
): string {
if (binding.kind === 'none') return 'None';
if ('direction' in binding) {
const name = getFriendlyAxisName(binding.axisIndex);
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
}
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex);
return getFriendlyAxisName(binding.axisIndex);
}
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
type ExpandedRowKey = string;
export function createControllerConfigForm(options: {
container: HTMLElement;
getBindings: () => ResolvedControllerConfig['bindings'];
getLearningActionId: () => ControllerBindingActionId | null;
getDpadLearningActionId: () => ControllerBindingActionId | null;
onLearn: (actionId: ControllerBindingActionId, bindingType: 'discrete' | 'axis') => void;
onClear: (actionId: ControllerBindingActionId) => void;
onReset: (actionId: ControllerBindingActionId) => void;
onDpadLearn: (actionId: ControllerBindingActionId) => void;
onDpadClear: (actionId: ControllerBindingActionId) => void;
onDpadReset: (actionId: ControllerBindingActionId) => void;
}) {
let expandedRowKey: ExpandedRowKey | null = null;
function render(): void {
options.container.innerHTML = '';
let lastGroup = '';
const learningActionId = options.getLearningActionId();
const dpadLearningActionId = options.getDpadLearningActionId();
// Auto-expand when learning starts
if (learningActionId) {
expandedRowKey = learningActionId;
} else if (dpadLearningActionId) {
expandedRowKey = `${dpadLearningActionId}:dpad`;
}
for (const definition of CONTROLLER_BINDING_DEFINITIONS) {
if (definition.group !== lastGroup) {
const header = document.createElement('div');
header.className = 'controller-config-group';
header.textContent = definition.group;
options.container.appendChild(header);
lastGroup = definition.group;
}
const binding = options.getBindings()[definition.id];
if (definition.bindingType === 'axis') {
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
renderAxisDpadRow(definition, binding as ResolvedControllerAxisBinding, dpadLearningActionId);
} else {
renderDiscreteRow(definition, binding, learningActionId);
}
}
}
function renderDiscreteRow(
definition: ControllerBindingDefinition,
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
learningActionId: ControllerBindingActionId | null,
): void {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const row = createRow(definition.label, formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const hint = isLearning
? 'Press a button, trigger, or move a stick\u2026'
: `Currently: ${formatControllerBindingSummary(binding)}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, definition.bindingType); },
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
});
options.container.appendChild(panel);
}
}
function renderAxisStickRow(
definition: ControllerBindingDefinition,
binding: ResolvedControllerAxisBinding,
learningActionId: ControllerBindingActionId | null,
): void {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const row = createRow(`${definition.label} (Stick)`, formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, 'axis'); },
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
});
options.container.appendChild(panel);
}
}
function renderAxisDpadRow(
definition: ControllerBindingDefinition,
binding: ResolvedControllerAxisBinding,
dpadLearningActionId: ControllerBindingActionId | null,
): void {
const rowKey = `${definition.id as string}:dpad`;
const isExpanded = expandedRowKey === rowKey;
const isLearning = dpadLearningActionId === definition.id;
const dpadFallback: ControllerDpadFallback = binding.kind === 'none' ? 'none' : binding.dpadFallback;
const badgeText = DPAD_FALLBACK_LABELS[dpadFallback];
const row = createRow(`${definition.label} (D-pad)`, badgeText, dpadFallback === 'none', isExpanded);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const hint = isLearning
? 'Press a D-pad direction\u2026'
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onDpadLearn(definition.id); },
onClear: (e) => { e.stopPropagation(); options.onDpadClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onDpadReset(definition.id); },
});
options.container.appendChild(panel);
}
}
function createRow(labelText: string, badgeText: string, isDisabled: boolean, isExpanded: boolean): HTMLDivElement {
const row = document.createElement('div');
row.className = 'controller-config-row';
if (isExpanded) row.classList.add('expanded');
const label = document.createElement('div');
label.className = 'controller-config-label';
label.textContent = labelText;
const right = document.createElement('div');
right.className = 'controller-config-right';
const badge = document.createElement('span');
badge.className = 'controller-config-badge';
if (isDisabled) badge.classList.add('disabled');
badge.textContent = badgeText;
const editIcon = document.createElement('span');
editIcon.className = 'controller-config-edit-icon';
editIcon.textContent = '\u270E';
right.appendChild(badge);
right.appendChild(editIcon);
row.appendChild(label);
row.appendChild(right);
return row;
}
function createEditPanel(
hintText: string,
isLearning: boolean,
callbacks: {
onLearn: (e: Event) => void;
onClear: (e: Event) => void;
onReset: (e: Event) => void;
},
): HTMLDivElement {
const panel = document.createElement('div');
panel.className = 'controller-config-edit-panel';
const inner = document.createElement('div');
inner.className = 'controller-config-edit-inner';
const hint = document.createElement('div');
hint.className = 'controller-config-edit-hint';
if (isLearning) hint.classList.add('learning');
hint.textContent = hintText;
const actions = document.createElement('div');
actions.className = 'controller-config-edit-actions';
const learnButton = document.createElement('button');
learnButton.type = 'button';
learnButton.className = isLearning ? 'btn-learn active' : 'btn-learn';
learnButton.textContent = isLearning ? 'Listening\u2026' : 'Learn';
learnButton.addEventListener('click', callbacks.onLearn);
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'btn-secondary';
clearButton.textContent = 'Clear';
clearButton.addEventListener('click', callbacks.onClear);
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.className = 'btn-secondary';
resetButton.textContent = 'Reset';
resetButton.addEventListener('click', callbacks.onReset);
actions.appendChild(learnButton);
actions.appendChild(clearButton);
actions.appendChild(resetButton);
inner.appendChild(hint);
inner.appendChild(actions);
panel.appendChild(inner);
return panel;
}
return { render };
}

View File

@@ -62,19 +62,19 @@ test('controller debug modal renders active controller axes, buttons, and config
rightTrigger: 7, rightTrigger: 7,
}, },
bindings: { bindings: {
toggleLookup: 'buttonSouth', toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: 'buttonEast', closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: 'buttonNorth', toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: 'buttonWest', mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: 'select', quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: 'none', previousAudio: { kind: 'none' },
nextAudio: 'rightShoulder', nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: 'leftShoulder', playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: 'leftStickPress', toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: 'leftStickX', leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: 'leftStickY', leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: 'rightStickX', rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: 'rightStickY', rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}, },
}; };
@@ -175,19 +175,19 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
rightTrigger: 7, rightTrigger: 7,
}, },
bindings: { bindings: {
toggleLookup: 'buttonSouth', toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: 'buttonEast', closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: 'buttonNorth', toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: 'buttonWest', mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: 'select', quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: 'none', previousAudio: { kind: 'none' },
nextAudio: 'rightShoulder', nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: 'leftShoulder', playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: 'leftStickPress', toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: 'leftStickX', leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: 'leftStickY', leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: 'rightStickX', rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: 'rightStickY', rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}, },
}; };

View File

@@ -27,55 +27,103 @@ function createClassList(initialTokens: string[] = []) {
}; };
} }
test('controller select modal saves the selected preferred controller', async () => { function createFakeElement() {
const attributes = new Map<string, string>();
const el = {
className: '',
textContent: '',
_innerHTML: '',
value: '',
disabled: false,
selected: false,
type: '',
children: [] as any[],
listeners: new Map<string, Array<(e?: any) => void>>(),
classList: createClassList(),
appendChild(child: any) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (e?: any) => void) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
},
dispatch(type: string) {
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
for (const listener of this.listeners.get(type) ?? []) {
listener(fakeEvent);
}
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
querySelector(selector: string) {
const match = selector.match(/^\[data-testid="(.+)"\]$/);
if (!match) return null;
const testId = match[1];
for (const child of el.children) {
if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) {
return child;
}
if (typeof child.querySelector === 'function') {
const nested = child.querySelector(selector);
if (nested) return nested;
}
}
return null;
},
focus: () => {},
};
Object.defineProperty(el, 'innerHTML', {
get() {
return el._innerHTML;
},
set(v: string) {
el._innerHTML = v;
if (v === '') el.children.length = 0;
},
});
return el;
}
function installFakeDom() {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window; const previousWindow = globals.window;
const previousDocument = globals.document; const previousDocument = globals.document;
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async (update: {
preferredGamepadId: string;
preferredGamepadLabel: string;
}) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', {
configurable: true, configurable: true,
value: { value: {
createElement: () => ({ createElement: () => createFakeElement(),
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
}, },
}); });
try { return {
const overlayClassList = createClassList(); restore: () => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
},
};
}
function buildContext() {
const state = createRendererState(); const state = createRendererState();
state.controllerConfig = { state.controllerConfig = {
enabled: true, enabled: true,
preferredGamepadId: 'pad-2', preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-2', preferredGamepadLabel: 'pad-1',
smoothScroll: true, smoothScroll: true,
scrollPixelsPerSecond: 960, scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160, horizontalJumpPixels: 160,
stickDeadzone: 0.2, stickDeadzone: 0.2,
triggerInputMode: 'auto', triggerInputMode: 'auto',
triggerDeadzone: 0.5, triggerDeadzone: 0.5,
repeatDelayMs: 220, repeatDelayMs: 320,
repeatIntervalMs: 80, repeatIntervalMs: 120,
buttonIndices: { buttonIndices: {
select: 6, select: 6,
buttonSouth: 0, buttonSouth: 0,
@@ -90,58 +138,74 @@ test('controller select modal saves the selected preferred controller', async ()
rightTrigger: 7, rightTrigger: 7,
}, },
bindings: { bindings: {
toggleLookup: 'buttonSouth', toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: 'buttonEast', closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: 'buttonNorth', toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: 'buttonWest', mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: 'select', quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: 'leftShoulder', previousAudio: { kind: 'none' },
nextAudio: 'rightShoulder', nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: 'rightTrigger', playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: 'leftTrigger', toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: 'leftStickX', leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: 'leftStickY', leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: 'rightStickX', rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: 'rightStickY', rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}, },
}; };
state.connectedGamepads = [ state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }, { id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }, { id: 'pad-2', index: 1, mapping: 'standard', connected: true },
]; ];
state.activeGamepadId = 'pad-2'; state.activeGamepadId = 'pad-1';
const ctx = { const dom = {
dom: { overlay: { classList: createClassList(), focus: () => {} },
overlay: { classList: overlayClassList, focus: () => {} }, controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
controllerSelectModal: { controllerSelectClose: createFakeElement(),
classList: createClassList(['hidden']), controllerSelectPicker: createFakeElement(),
setAttribute: () => {}, controllerSelectSummary: createFakeElement(),
}, controllerConfigList: createFakeElement(),
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() }, controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: { controllerSelectSave: createFakeElement(),
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
}; };
const modal = createControllerSelectModal(ctx as never, { return { state, dom };
}
test('controller select modal saves preferred controller from dropdown selection', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false }, modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {}, syncSettingsModalSubtitleSuppression: () => {},
}); });
modal.wireDomEvents();
modal.openControllerSelectModal(); modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1); state.controllerDeviceSelectedIndex = 1;
await modal.handleControllerSelectKeydown({ await modal.handleControllerSelectKeydown({
key: 'Enter', key: 'Enter',
preventDefault: () => {}, preventDefault: () => {},
} as KeyboardEvent); } as KeyboardEvent);
await Promise.resolve();
assert.deepEqual(saved, [ assert.deepEqual(saved, [
{ {
@@ -150,578 +214,114 @@ test('controller select modal saves the selected preferred controller', async ()
}, },
]); ]);
} finally { } finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); domHandle.restore();
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
} }
}); });
test('controller select modal preserves manual selection while controller polling updates', async () => { test('controller select modal learn mode captures fresh button input and persists binding', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const domHandle = installFakeDom();
const previousWindow = globals.window; const saved: unknown[] = [];
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', { Object.defineProperty(globalThis, 'window', {
configurable: true, configurable: true,
value: { value: {
focus: () => {}, focus: () => {},
electronAPI: { electronAPI: {
saveControllerPreference: async () => {}, saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {}, notifyOverlayModalClosed: () => {},
}, },
}, },
}); });
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try { try {
const state = createRendererState(); const { state, dom } = buildContext();
state.controllerConfig = { const modal = createControllerSelectModal({ state, dom } as never, {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false }, modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {}, syncSettingsModalSubtitleSuppression: () => {},
}); });
modal.wireDomEvents();
modal.openControllerSelectModal(); modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 0);
modal.handleControllerSelectKeydown({ // In the new compact list layout, children are:
key: 'ArrowDown', // [0] group header, [1] first binding row, [2] second binding row, ...
preventDefault: () => {}, // Click the row to expand the inline edit panel
} as KeyboardEvent); const firstRow = dom.controllerConfigList.children[1];
assert.equal(state.controllerDeviceSelectedIndex, 1); firstRow.dispatch('click');
// After expanding, the edit panel is inserted after the row:
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
const editPanel = dom.controllerConfigList.children[2];
// editPanel > inner > actions > learnButton
const inner = editPanel.children[0];
const actions = inner.children[1];
const learnButton = actions.children[0];
learnButton.dispatch('click');
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices(); modal.updateDevices();
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
});
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
} finally {
domHandle.restore();
}
});
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
const domHandle = installFakeDom();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
state.connectedGamepads = [
{ id: 'same-pad', index: 0, mapping: 'standard', connected: true },
{ id: 'same-pad', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'same-pad';
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const [firstOption, secondOption] = dom.controllerSelectPicker.children;
assert.notEqual(firstOption.value, secondOption.value);
dom.controllerSelectPicker.value = secondOption.value;
dom.controllerSelectPicker.dispatch('change');
assert.equal(state.controllerDeviceSelectedIndex, 1); assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally { } finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); domHandle.restore();
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal prefers active controller over saved preferred controller', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves saved status across polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
modal.updateDevices();
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal surfaces save errors without mutating saved preference', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {
throw new Error('disk write failed');
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let appendCount = 0;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {
appendCount += 1;
},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
const initialAppendCount = appendCount;
modal.updateDevices();
assert.equal(appendCount, initialAppendCount);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
} }
}); });

View File

@@ -1,4 +1,11 @@
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import {
createControllerConfigForm,
getControllerBindingDefinition,
getDefaultControllerBinding,
getDefaultDpadFallback,
} from './controller-config-form.js';
function clampSelectedIndex(ctx: RendererContext): void { function clampSelectedIndex(ctx: RendererContext): void {
if (ctx.state.connectedGamepads.length === 0) { if (ctx.state.connectedGamepads.length === 0) {
@@ -19,10 +26,104 @@ export function createControllerSelectModal(
syncSettingsModalSubtitleSuppression: () => void; syncSettingsModalSubtitleSuppression: () => void;
}, },
) { ) {
let selectedControllerId: string | null = null; let selectedControllerKey: string | null = null;
let lastRenderedDevicesKey = ''; let lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null; let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = ''; let lastRenderedPreferredId = '';
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
type ControllerBindingValue =
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
let learningActionId: ControllerBindingKey | null = null;
let dpadLearningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList,
getBindings: () =>
ctx.state.controllerConfig?.bindings ?? {
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' },
},
getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => dpadLearningActionId,
onLearn: (actionId, bindingType) => {
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
dpadLearningActionId = null;
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
const currentBinding = config?.bindings[actionId];
const currentDpadFallback =
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
? currentBinding.dpadFallback
: 'none';
bindingCapture.arm(
bindingType === 'axis'
? {
actionId,
bindingType: 'axis',
dpadFallback: currentDpadFallback,
}
: {
actionId,
bindingType: 'discrete',
},
{
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
},
);
learningActionId = actionId;
controllerConfigForm.render();
setStatus(`Waiting for input for ${definition.label}.`);
},
onClear: (actionId) => {
void saveBinding(actionId, { kind: 'none' });
},
onReset: (actionId) => {
void saveBinding(actionId, getDefaultControllerBinding(actionId));
},
onDpadLearn: (actionId) => {
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
learningActionId = null;
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
bindingCapture.arm(
{ actionId, bindingType: 'dpad' },
{
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
},
);
dpadLearningActionId = actionId;
controllerConfigForm.render();
setStatus(`Press a D-pad direction for ${definition.label}.`);
},
onDpadClear: (actionId) => {
void saveDpadFallback(actionId, 'none');
},
onDpadReset: (actionId) => {
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
},
});
function getDevicesKey(): string { function getDevicesKey(): string {
return ctx.state.connectedGamepads return ctx.state.connectedGamepads
@@ -30,9 +131,13 @@ export function createControllerSelectModal(
.join('||'); .join('||');
} }
function getDeviceSelectionKey(device: { id: string; index: number }): string {
return `${device.id}:${device.index}`;
}
function syncSelectedControllerId(): void { function syncSelectedControllerId(): void {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex]; const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
selectedControllerId = selected?.id ?? null; selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
} }
function syncSelectedIndexToCurrentController(): void { function syncSelectedIndexToCurrentController(): void {
@@ -62,90 +167,93 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectStatus.classList.toggle('error', isError); ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
} }
function renderList(): void { function renderPicker(): void {
ctx.dom.controllerSelectList.innerHTML = ''; ctx.dom.controllerSelectPicker.innerHTML = '';
clampSelectedIndex(ctx); clampSelectedIndex(ctx);
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? ''; const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
ctx.state.connectedGamepads.forEach((device, index) => { ctx.state.connectedGamepads.forEach((device, index) => {
const li = document.createElement('li'); const option = document.createElement('option');
li.className = 'runtime-options-list-entry'; option.value = getDeviceSelectionKey(device);
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
const button = document.createElement('button'); option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
button.type = 'button'; `#${device.index}`,
button.className = 'runtime-options-item runtime-options-item-button'; device.mapping || 'unknown',
button.classList.toggle('active', index === ctx.state.controllerDeviceSelectedIndex);
const label = document.createElement('div');
label.className = 'runtime-options-label';
label.textContent = device.id || `Gamepad ${device.index}`;
const meta = document.createElement('div');
meta.className = 'runtime-options-value';
const tags = [
`Index ${device.index}`,
device.mapping || 'unknown mapping',
device.id === ctx.state.activeGamepadId ? 'active' : null, device.id === ctx.state.activeGamepadId ? 'active' : null,
device.id === preferredId ? 'saved' : null, device.id === preferredId ? 'saved' : null,
].filter(Boolean); ]
meta.textContent = tags.join(' · '); .filter(Boolean)
.join(', ')})`;
ctx.dom.controllerSelectPicker.appendChild(option);
});
button.appendChild(label); ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
button.appendChild(meta); ctx.dom.controllerSelectSummary.textContent =
button.addEventListener('click', () => { ctx.state.connectedGamepads.length === 0
ctx.state.controllerDeviceSelectedIndex = index; ? 'No controller detected.'
syncSelectedControllerId(); : `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
renderList();
});
button.addEventListener('dblclick', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
void saveSelectedController();
});
li.appendChild(button);
ctx.dom.controllerSelectList.appendChild(li);
});
lastRenderedDevicesKey = getDevicesKey(); lastRenderedDevicesKey = getDevicesKey();
lastRenderedActiveGamepadId = ctx.state.activeGamepadId; lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
lastRenderedPreferredId = preferredId; lastRenderedPreferredId = preferredId;
} }
function updateDevices(): void { async function saveControllerConfig(update: Parameters<typeof window.electronAPI.saveControllerConfig>[0]) {
if (!ctx.state.controllerSelectModalOpen) return; await window.electronAPI.saveControllerConfig(update);
if (selectedControllerId) { if (!ctx.state.controllerConfig) return;
const preservedIndex = ctx.state.connectedGamepads.findIndex( if (update.preferredGamepadId !== undefined) {
(device) => device.id === selectedControllerId, ctx.state.controllerConfig.preferredGamepadId = update.preferredGamepadId;
); }
if (preservedIndex >= 0) { if (update.preferredGamepadLabel !== undefined) {
ctx.state.controllerDeviceSelectedIndex = preservedIndex; ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
} else { }
syncSelectedIndexToCurrentController(); if (update.bindings) {
ctx.state.controllerConfig.bindings = {
...ctx.state.controllerConfig.bindings,
...update.bindings,
} as typeof ctx.state.controllerConfig.bindings;
} }
} else {
syncSelectedIndexToCurrentController();
} }
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? ''; async function saveBinding(
const shouldRender = actionId: ControllerBindingKey,
getDevicesKey() !== lastRenderedDevicesKey || binding: ControllerBindingValue,
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId || ): Promise<void> {
preferredId !== lastRenderedPreferredId; const definition = getControllerBindingDefinition(actionId);
if (shouldRender) { try {
renderList(); await saveControllerConfig({
bindings: {
[actionId]: binding,
},
});
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus(`${definition?.label ?? actionId} updated.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save binding: ${message}`, true);
}
} }
if (ctx.state.connectedGamepads.length === 0) { async function saveDpadFallback(
setStatus('No controllers detected.'); actionId: ControllerBindingKey,
return; dpadFallback: import('../../types').ControllerDpadFallback,
} ): Promise<void> {
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim(); const definition = getControllerBindingDefinition(actionId);
if ( const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
currentStatus !== 'No controller selected.' && if (!currentBinding || currentBinding.kind !== 'axis') return;
!currentStatus.startsWith('Saved preferred controller:') const updated = { ...currentBinding, dpadFallback };
) { try {
setStatus('Select a controller to save as preferred.'); await saveControllerConfig({ bindings: { [actionId]: updated } });
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus(`${definition?.label ?? actionId} D-pad updated.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save D-pad binding: ${message}`, true);
} }
} }
@@ -157,7 +265,7 @@ export function createControllerSelectModal(
} }
try { try {
await window.electronAPI.saveControllerPreference({ await saveControllerConfig({
preferredGamepadId: selected.id, preferredGamepadId: selected.id,
preferredGamepadLabel: selected.id, preferredGamepadLabel: selected.id,
}); });
@@ -167,15 +275,55 @@ export function createControllerSelectModal(
return; return;
} }
if (ctx.state.controllerConfig) {
ctx.state.controllerConfig.preferredGamepadId = selected.id;
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
}
syncSelectedControllerId(); syncSelectedControllerId();
renderList(); renderPicker();
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`); setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
} }
function updateDevices(): void {
if (!ctx.state.controllerSelectModalOpen) return;
if (selectedControllerKey) {
const preservedIndex = ctx.state.connectedGamepads.findIndex(
(device) => getDeviceSelectionKey(device) === selectedControllerKey,
);
if (preservedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
} else {
syncSelectedIndexToCurrentController();
}
} else {
syncSelectedIndexToCurrentController();
}
if (bindingCapture && (learningActionId || dpadLearningActionId)) {
const result = bindingCapture.poll({
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
});
if (result) {
if (result.bindingType === 'dpad') {
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
} else {
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
}
}
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
preferredId !== lastRenderedPreferredId;
if (shouldRender) {
renderPicker();
controllerConfigForm.render();
}
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
setStatus('No controllers detected.');
}
}
function openControllerSelectModal(): void { function openControllerSelectModal(): void {
ctx.state.controllerSelectModalOpen = true; ctx.state.controllerSelectModalOpen = true;
syncSelectedIndexToCurrentController(); syncSelectedIndexToCurrentController();
@@ -185,16 +333,20 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false'); ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
window.focus(); window.focus();
ctx.dom.overlay.focus({ preventScroll: true }); ctx.dom.overlay.focus({ preventScroll: true });
renderList(); renderPicker();
controllerConfigForm.render();
if (ctx.state.connectedGamepads.length === 0) { if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.'); setStatus('No controllers detected.');
} else { } else {
setStatus('Select a controller to save as preferred.'); setStatus('Choose a controller or click Learn to remap an action.');
} }
} }
function closeControllerSelectModal(): void { function closeControllerSelectModal(): void {
if (!ctx.state.controllerSelectModalOpen) return; if (!ctx.state.controllerSelectModalOpen) return;
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
ctx.state.controllerSelectModalOpen = false; ctx.state.controllerSelectModalOpen = false;
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden'); ctx.dom.controllerSelectModal.classList.add('hidden');
@@ -208,6 +360,14 @@ export function createControllerSelectModal(
function handleControllerSelectKeydown(event: KeyboardEvent): boolean { function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
if (learningActionId || dpadLearningActionId) {
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus('Controller learn mode cancelled.');
return true;
}
closeControllerSelectModal(); closeControllerSelectModal();
return true; return true;
} }
@@ -220,7 +380,7 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex + 1, ctx.state.controllerDeviceSelectedIndex + 1,
); );
syncSelectedControllerId(); syncSelectedControllerId();
renderList(); renderPicker();
} }
return true; return true;
} }
@@ -233,12 +393,12 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex - 1, ctx.state.controllerDeviceSelectedIndex - 1,
); );
syncSelectedControllerId(); syncSelectedControllerId();
renderList(); renderPicker();
} }
return true; return true;
} }
if (event.key === 'Enter') { if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
event.preventDefault(); event.preventDefault();
void saveSelectedController(); void saveSelectedController();
return true; return true;
@@ -254,6 +414,17 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectSave.addEventListener('click', () => { ctx.dom.controllerSelectSave.addEventListener('click', () => {
void saveSelectedController(); void saveSelectedController();
}); });
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
const selectedKey = ctx.dom.controllerSelectPicker.value;
const selectedIndex = ctx.state.connectedGamepads.findIndex(
(device) => getDeviceSelectionKey(device) === selectedKey,
);
if (selectedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
syncSelectedControllerId();
renderPicker();
}
});
} }
return { return {

View File

@@ -280,19 +280,19 @@ function startControllerPolling(): void {
rightTrigger: 7, rightTrigger: 7,
}, },
bindings: { bindings: {
toggleLookup: 'buttonSouth', toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: 'buttonEast', closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: 'buttonNorth', toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: 'buttonWest', mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: 'select', quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: 'none', previousAudio: { kind: 'none' },
nextAudio: 'rightShoulder', nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: 'leftShoulder', playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: 'leftStickPress', toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: 'leftStickX', leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: 'leftStickY', leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: 'rightStickX', rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: 'rightStickY', rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}, },
}, },
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled, getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,

View File

@@ -1105,6 +1105,197 @@ iframe[id^='yomitan-popup'] {
color: #ff8f8f; color: #ff8f8f;
} }
.controller-select-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.controller-select-field select {
min-height: 38px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
background: rgba(10, 14, 20, 0.9);
color: rgba(255, 255, 255, 0.94);
}
.controller-select-summary {
margin-bottom: 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.controller-config-list {
display: flex;
flex-direction: column;
max-height: 400px;
overflow-y: auto;
margin-bottom: 12px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.controller-config-group {
margin-top: 14px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(120, 190, 255, 0.9);
}
.controller-config-group:first-child {
margin-top: 0;
}
.controller-config-row {
display: flex;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: transparent;
cursor: pointer;
transition: background 120ms ease;
}
.controller-config-row:last-child {
border-bottom: none;
}
.controller-config-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.controller-config-row.expanded {
background: rgba(100, 180, 255, 0.06);
border-color: rgba(100, 180, 255, 0.15);
}
.controller-config-label {
flex: 1;
min-width: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.95);
}
.controller-config-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.controller-config-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
background: rgba(100, 180, 255, 0.12);
color: rgba(100, 180, 255, 0.95);
white-space: nowrap;
}
.controller-config-badge.disabled {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
}
.controller-config-edit-icon {
font-size: 14px;
color: rgba(255, 255, 255, 0.3);
transition: color 120ms ease;
}
.controller-config-row:hover .controller-config-edit-icon {
color: rgba(255, 255, 255, 0.6);
}
.controller-config-edit-panel {
overflow: hidden;
animation: configEditSlideIn 180ms ease-out;
border-bottom: 1px solid rgba(100, 180, 255, 0.12);
background: rgba(100, 180, 255, 0.04);
}
@keyframes configEditSlideIn {
from { max-height: 0; opacity: 0; }
to { max-height: 120px; opacity: 1; }
}
.controller-config-edit-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
}
.controller-config-edit-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
.controller-config-edit-hint.learning {
color: rgba(100, 180, 255, 0.95);
animation: configLearnPulse 1.2s ease-in-out infinite;
}
@keyframes configLearnPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.controller-config-edit-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.btn-learn {
padding: 5px 14px;
border-radius: 5px;
border: 1px solid rgba(100, 180, 255, 0.4);
background: rgba(100, 180, 255, 0.15);
color: rgba(100, 180, 255, 0.95);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease;
}
.btn-learn:hover {
background: rgba(100, 180, 255, 0.25);
}
.btn-learn.active {
border-color: rgba(100, 180, 255, 0.7);
background: rgba(100, 180, 255, 0.25);
}
.btn-secondary {
padding: 5px 12px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
.controller-debug-content { .controller-debug-content {
position: relative; position: relative;
width: min(760px, 94%); width: min(760px, 94%);

View File

@@ -59,9 +59,10 @@ export type RendererDom = {
controllerSelectModal: HTMLDivElement; controllerSelectModal: HTMLDivElement;
controllerSelectClose: HTMLButtonElement; controllerSelectClose: HTMLButtonElement;
controllerSelectHint: HTMLDivElement; controllerSelectPicker: HTMLSelectElement;
controllerSelectSummary: HTMLDivElement;
controllerSelectStatus: HTMLDivElement; controllerSelectStatus: HTMLDivElement;
controllerSelectList: HTMLUListElement; controllerConfigList: HTMLDivElement;
controllerSelectSave: HTMLButtonElement; controllerSelectSave: HTMLButtonElement;
controllerDebugModal: HTMLDivElement; controllerDebugModal: HTMLDivElement;
@@ -153,9 +154,10 @@ export function resolveRendererDom(): RendererDom {
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'), controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'), controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'), controllerSelectPicker: getRequiredElement<HTMLSelectElement>('controllerSelectPicker'),
controllerSelectSummary: getRequiredElement<HTMLDivElement>('controllerSelectSummary'),
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'), controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'), controllerConfigList: getRequiredElement<HTMLDivElement>('controllerConfigList'),
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'), controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'), controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),

View File

@@ -19,6 +19,7 @@ export const IPC_CHANNELS = {
toggleDevTools: 'toggle-dev-tools', toggleDevTools: 'toggle-dev-tools',
toggleOverlay: 'toggle-overlay', toggleOverlay: 'toggle-overlay',
saveSubtitlePosition: 'save-subtitle-position', saveSubtitlePosition: 'save-subtitle-position',
saveControllerConfig: 'save-controller-config',
saveControllerPreference: 'save-controller-preference', saveControllerPreference: 'save-controller-preference',
setMecabEnabled: 'set-mecab-enabled', setMecabEnabled: 'set-mecab-enabled',
mpvCommand: 'mpv-command', mpvCommand: 'mpv-command',

View File

@@ -1,4 +1,5 @@
import type { import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
JimakuDownloadQuery, JimakuDownloadQuery,
JimakuFilesQuery, JimakuFilesQuery,
@@ -59,6 +60,99 @@ export function parseControllerPreferenceUpdate(value: unknown): ControllerPrefe
}; };
} }
function parseDiscreteBinding(value: unknown) {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
if (!isInteger(value.buttonIndex) || value.buttonIndex < 0) return null;
return { kind: 'button', buttonIndex: value.buttonIndex };
}
if (value.kind === 'axis') {
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
if (value.direction !== 'negative' && value.direction !== 'positive') return null;
return { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction };
}
return null;
}
function parseAxisBinding(value: unknown) {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
if (
value.dpadFallback !== undefined &&
value.dpadFallback !== 'none' &&
value.dpadFallback !== 'horizontal' &&
value.dpadFallback !== 'vertical'
) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
...(value.dpadFallback === undefined ? {} : { dpadFallback: value.dpadFallback }),
};
}
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
if (!isObject(value)) return null;
const update: ControllerConfigUpdate = {};
if (value.enabled !== undefined) {
if (typeof value.enabled !== 'boolean') return null;
update.enabled = value.enabled;
}
if (value.preferredGamepadId !== undefined) {
if (typeof value.preferredGamepadId !== 'string') return null;
update.preferredGamepadId = value.preferredGamepadId;
}
if (value.preferredGamepadLabel !== undefined) {
if (typeof value.preferredGamepadLabel !== 'string') return null;
update.preferredGamepadLabel = value.preferredGamepadLabel;
}
if (value.bindings !== undefined) {
if (!isObject(value.bindings)) return null;
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
const discreteKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of discreteKeys) {
if (value.bindings[key] === undefined) continue;
const parsed = parseDiscreteBinding(value.bindings[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
const axisKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisKeys) {
if (value.bindings[key] === undefined) continue;
const parsed = parseAxisBinding(value.bindings[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
update.bindings = bindings;
}
return update;
}
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null { export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
if (!isObject(value)) return null; if (!isObject(value)) return null;
const { engine, sourceTrackId } = value; const { engine, sourceTrackId } = value;

View File

@@ -391,21 +391,84 @@ export type ControllerButtonBinding =
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY'; export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog'; export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
export type ControllerAxisDirection = 'negative' | 'positive';
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
export interface ControllerNoneBinding {
kind: 'none';
}
export interface ControllerButtonInputBinding {
kind: 'button';
buttonIndex: number;
}
export interface ControllerAxisDirectionInputBinding {
kind: 'axis';
axisIndex: number;
direction: ControllerAxisDirection;
}
export interface ControllerAxisInputBinding {
kind: 'axis';
axisIndex: number;
dpadFallback?: ControllerDpadFallback;
}
export type ControllerDiscreteBindingConfig =
| ControllerButtonBinding
| ControllerNoneBinding
| ControllerButtonInputBinding
| ControllerAxisDirectionInputBinding;
export type ResolvedControllerDiscreteBinding =
| ControllerNoneBinding
| ControllerButtonInputBinding
| ControllerAxisDirectionInputBinding;
export type ControllerAxisBindingConfig =
| ControllerAxisBinding
| ControllerNoneBinding
| ControllerAxisInputBinding;
export type ResolvedControllerAxisBinding =
| ControllerNoneBinding
| {
kind: 'axis';
axisIndex: number;
dpadFallback: ControllerDpadFallback;
};
export interface ControllerBindingsConfig { export interface ControllerBindingsConfig {
toggleLookup?: ControllerButtonBinding; toggleLookup?: ControllerDiscreteBindingConfig;
closeLookup?: ControllerButtonBinding; closeLookup?: ControllerDiscreteBindingConfig;
toggleKeyboardOnlyMode?: ControllerButtonBinding; toggleKeyboardOnlyMode?: ControllerDiscreteBindingConfig;
mineCard?: ControllerButtonBinding; mineCard?: ControllerDiscreteBindingConfig;
quitMpv?: ControllerButtonBinding; quitMpv?: ControllerDiscreteBindingConfig;
previousAudio?: ControllerButtonBinding; previousAudio?: ControllerDiscreteBindingConfig;
nextAudio?: ControllerButtonBinding; nextAudio?: ControllerDiscreteBindingConfig;
playCurrentAudio?: ControllerButtonBinding; playCurrentAudio?: ControllerDiscreteBindingConfig;
toggleMpvPause?: ControllerButtonBinding; toggleMpvPause?: ControllerDiscreteBindingConfig;
leftStickHorizontal?: ControllerAxisBinding; leftStickHorizontal?: ControllerAxisBindingConfig;
leftStickVertical?: ControllerAxisBinding; leftStickVertical?: ControllerAxisBindingConfig;
rightStickHorizontal?: ControllerAxisBinding; rightStickHorizontal?: ControllerAxisBindingConfig;
rightStickVertical?: ControllerAxisBinding; rightStickVertical?: ControllerAxisBindingConfig;
}
export interface ResolvedControllerBindingsConfig {
toggleLookup?: ResolvedControllerDiscreteBinding;
closeLookup?: ResolvedControllerDiscreteBinding;
toggleKeyboardOnlyMode?: ResolvedControllerDiscreteBinding;
mineCard?: ResolvedControllerDiscreteBinding;
quitMpv?: ResolvedControllerDiscreteBinding;
previousAudio?: ResolvedControllerDiscreteBinding;
nextAudio?: ResolvedControllerDiscreteBinding;
playCurrentAudio?: ResolvedControllerDiscreteBinding;
toggleMpvPause?: ResolvedControllerDiscreteBinding;
leftStickHorizontal?: ResolvedControllerAxisBinding;
leftStickVertical?: ResolvedControllerAxisBinding;
rightStickHorizontal?: ResolvedControllerAxisBinding;
rightStickVertical?: ResolvedControllerAxisBinding;
} }
export interface ControllerButtonIndicesConfig { export interface ControllerButtonIndicesConfig {
@@ -443,6 +506,8 @@ export interface ControllerPreferenceUpdate {
preferredGamepadLabel: string; preferredGamepadLabel: string;
} }
export type ControllerConfigUpdate = ControllerConfig;
export interface ControllerDeviceInfo { export interface ControllerDeviceInfo {
id: string; id: string;
index: number; index: number;
@@ -621,7 +686,7 @@ export interface ResolvedConfig {
repeatDelayMs: number; repeatDelayMs: number;
repeatIntervalMs: number; repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>; buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ControllerBindingsConfig>; bindings: Required<ResolvedControllerBindingsConfig>;
}; };
ankiConnect: AnkiConnectConfig & { ankiConnect: AnkiConnectConfig & {
enabled: boolean; enabled: boolean;
@@ -977,6 +1042,7 @@ export interface ElectronAPI {
getKeybindings: () => Promise<Keybinding[]>; getKeybindings: () => Promise<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>; getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getControllerConfig: () => Promise<ResolvedControllerConfig>; getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>; saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>; getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>; jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;