mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(controller): add inline remap modal with descriptor-based bindings (#21)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/*
|
||||||
|
|||||||
@@ -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 -->
|
||||||
4
changes/controller-inline-remap.md
Normal file
4
changes/controller-inline-remap.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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.',
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.closeLookup',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.closeLookup,
|
|
||||||
description: 'Controller binding for closing lookup.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.toggleKeyboardOnlyMode',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
|
|
||||||
description: 'Controller binding for toggling keyboard-only mode.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.mineCard',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.mineCard,
|
|
||||||
description: 'Controller binding for mining the active card.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.quitMpv',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.quitMpv,
|
|
||||||
description: 'Controller binding for quitting mpv.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.previousAudio',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.previousAudio,
|
|
||||||
description: 'Controller binding for previous Yomitan audio.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.nextAudio',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.nextAudio,
|
|
||||||
description: 'Controller binding for next Yomitan audio.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.playCurrentAudio',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
|
|
||||||
description: 'Controller binding for playing the current Yomitan audio.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.toggleMpvPause',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: controllerButtonEnumValues,
|
|
||||||
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
|
||||||
description: 'Controller binding for toggling mpv play/pause.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.leftStickHorizontal',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
|
||||||
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
|
|
||||||
description: 'Axis binding used for left/right token selection.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.leftStickVertical',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
|
||||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
|
||||||
description: 'Axis binding used for primary popup scrolling.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.rightStickHorizontal',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
|
||||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
|
||||||
description: 'Axis binding reserved for alternate right-stick mappings.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings.rightStickVertical',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
|
||||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
|
||||||
description: 'Axis binding used for popup page jumps.',
|
|
||||||
},
|
},
|
||||||
|
...discreteBindings.flatMap((binding) => [
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}`,
|
||||||
|
kind: 'object' as const,
|
||||||
|
defaultValue: binding.defaultValue,
|
||||||
|
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.kind`,
|
||||||
|
kind: 'enum' as const,
|
||||||
|
enumValues: ['none', 'button', 'axis'],
|
||||||
|
defaultValue: binding.defaultValue.kind,
|
||||||
|
description:
|
||||||
|
'Discrete binding input source kind. When kind is "axis", set both axisIndex and direction.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.buttonIndex`,
|
||||||
|
kind: 'number' as const,
|
||||||
|
defaultValue:
|
||||||
|
binding.defaultValue.kind === 'button' ? binding.defaultValue.buttonIndex : undefined,
|
||||||
|
description: 'Raw button index captured for this discrete controller action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.axisIndex`,
|
||||||
|
kind: 'number' as const,
|
||||||
|
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
|
||||||
|
description: 'Raw axis index captured for this discrete controller action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.direction`,
|
||||||
|
kind: 'enum' as const,
|
||||||
|
enumValues: ['negative', 'positive'],
|
||||||
|
defaultValue:
|
||||||
|
binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined,
|
||||||
|
description:
|
||||||
|
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...axisBindings.flatMap((binding) => [
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}`,
|
||||||
|
kind: 'object' as const,
|
||||||
|
defaultValue: binding.defaultValue,
|
||||||
|
description: `${binding.description} Use Alt+C learn mode or set a raw axis descriptor manually.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.kind`,
|
||||||
|
kind: 'enum' as const,
|
||||||
|
enumValues: ['none', 'axis'],
|
||||||
|
defaultValue: binding.defaultValue.kind,
|
||||||
|
description: 'Analog binding input source kind.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.axisIndex`,
|
||||||
|
kind: 'number' as const,
|
||||||
|
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
|
||||||
|
description: 'Raw axis index captured for this analog controller action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.dpadFallback`,
|
||||||
|
kind: 'enum' as const,
|
||||||
|
enumValues: ['none', 'horizontal', 'vertical'],
|
||||||
|
defaultValue:
|
||||||
|
binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined,
|
||||||
|
description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.',
|
||||||
|
},
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
path: 'texthooker.launchAtStartup',
|
path: 'texthooker.launchAtStartup',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -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.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,28 +1,141 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_BINDINGS = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||||
|
leftStickX: 0,
|
||||||
|
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 {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
const controllerButtonBindings = [
|
|
||||||
'none',
|
|
||||||
'select',
|
|
||||||
'buttonSouth',
|
|
||||||
'buttonEast',
|
|
||||||
'buttonNorth',
|
|
||||||
'buttonWest',
|
|
||||||
'leftShoulder',
|
|
||||||
'rightShoulder',
|
|
||||||
'leftStickPress',
|
|
||||||
'rightStickPress',
|
|
||||||
'leftTrigger',
|
|
||||||
'rightTrigger',
|
|
||||||
] as const;
|
|
||||||
const controllerAxisBindings = [
|
|
||||||
'leftStickX',
|
|
||||||
'leftStickY',
|
|
||||||
'rightStickX',
|
|
||||||
'rightStickY',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
if (isObject(src.texthooker)) {
|
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'.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: () => '',
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
54
src/main/controller-config-update.test.ts
Normal file
54
src/main/controller-config-update.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
38
src/main/controller-config-update.ts
Normal file
38
src/main/controller-config-update.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
129
src/renderer/handlers/controller-binding-capture.test.ts
Normal file
129
src/renderer/handlers/controller-binding-capture.test.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
194
src/renderer/handlers/controller-binding-capture.ts
Normal file
194
src/renderer/handlers/controller-binding-capture.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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(
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
binding,
|
|
||||||
config.triggerInputMode,
|
|
||||||
config.triggerDeadzone,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionValue = (() => {
|
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
const selectionValue = resolveAxisBindingValue(
|
||||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
activeGamepad,
|
||||||
return axisValue;
|
config.bindings.leftStickHorizontal,
|
||||||
}
|
config.triggerDeadzone,
|
||||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
activationThreshold,
|
||||||
})();
|
);
|
||||||
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
|
syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold);
|
||||||
|
|
||||||
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,
|
||||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
config.stickDeadzone,
|
||||||
}
|
);
|
||||||
if (dpadVertical !== 0) {
|
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||||
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
options.scrollPopup((primaryScroll * 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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
146
src/renderer/modals/controller-config-form.test.ts
Normal file
146
src/renderer/modals/controller-config-form.test.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
429
src/renderer/modals/controller-config-form.ts
Normal file
429
src/renderer/modals/controller-config-form.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,121 +27,185 @@ 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, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
restore: () => {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContext() {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 320,
|
||||||
|
repeatIntervalMs: 120,
|
||||||
|
buttonIndices: {
|
||||||
|
select: 6,
|
||||||
|
buttonSouth: 0,
|
||||||
|
buttonEast: 1,
|
||||||
|
buttonWest: 2,
|
||||||
|
buttonNorth: 3,
|
||||||
|
leftShoulder: 4,
|
||||||
|
rightShoulder: 5,
|
||||||
|
leftStickPress: 9,
|
||||||
|
rightStickPress: 10,
|
||||||
|
leftTrigger: 6,
|
||||||
|
rightTrigger: 7,
|
||||||
|
},
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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 dom = {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||||
|
controllerSelectClose: createFakeElement(),
|
||||||
|
controllerSelectPicker: createFakeElement(),
|
||||||
|
controllerSelectSummary: createFakeElement(),
|
||||||
|
controllerConfigList: createFakeElement(),
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectSave: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { state, dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('controller select modal saves preferred controller from dropdown selection', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
Object.defineProperty(globalThis, 'window', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
focus: () => {},
|
focus: () => {},
|
||||||
electronAPI: {
|
electronAPI: {
|
||||||
saveControllerPreference: async (update: {
|
saveControllerConfig: async (update: unknown) => {
|
||||||
preferredGamepadId: string;
|
|
||||||
preferredGamepadLabel: string;
|
|
||||||
}) => {
|
|
||||||
saved.push(update);
|
saved.push(update);
|
||||||
},
|
},
|
||||||
notifyOverlayModalClosed: () => {},
|
notifyOverlayModalClosed: () => {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
createElement: () => ({
|
|
||||||
className: '',
|
|
||||||
textContent: '',
|
|
||||||
classList: createClassList(),
|
|
||||||
appendChild: () => {},
|
|
||||||
addEventListener: () => {},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const overlayClassList = createClassList();
|
const { state, dom } = buildContext();
|
||||||
const state = createRendererState();
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
state.controllerConfig = {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: 'pad-2',
|
|
||||||
preferredGamepadLabel: 'pad-2',
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
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: overlayClassList, 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, 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 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(', ')})`;
|
||||||
button.appendChild(label);
|
ctx.dom.controllerSelectPicker.appendChild(option);
|
||||||
button.appendChild(meta);
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = index;
|
|
||||||
syncSelectedControllerId();
|
|
||||||
renderList();
|
|
||||||
});
|
|
||||||
button.addEventListener('dblclick', () => {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = index;
|
|
||||||
syncSelectedControllerId();
|
|
||||||
void saveSelectedController();
|
|
||||||
});
|
|
||||||
li.appendChild(button);
|
|
||||||
|
|
||||||
ctx.dom.controllerSelectList.appendChild(li);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
|
||||||
|
ctx.dom.controllerSelectSummary.textContent =
|
||||||
|
ctx.state.connectedGamepads.length === 0
|
||||||
|
? 'No controller detected.'
|
||||||
|
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
|
||||||
|
|
||||||
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) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
|
||||||
} else {
|
|
||||||
syncSelectedIndexToCurrentController();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
syncSelectedIndexToCurrentController();
|
|
||||||
}
|
}
|
||||||
|
if (update.preferredGamepadLabel !== undefined) {
|
||||||
|
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
|
||||||
|
}
|
||||||
|
if (update.bindings) {
|
||||||
|
ctx.state.controllerConfig.bindings = {
|
||||||
|
...ctx.state.controllerConfig.bindings,
|
||||||
|
...update.bindings,
|
||||||
|
} as typeof ctx.state.controllerConfig.bindings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
94
src/types.ts
94
src/types.ts
@@ -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[]>>;
|
||||||
|
|||||||
Reference in New Issue
Block a user