mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Add overlay gamepad support for keyboard-only mode (#17)
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 (2026-03-12)
|
||||
|
||||
### Added
|
||||
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||
- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||
- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||
|
||||
### Internal
|
||||
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||
|
||||
## v0.5.6 (2026-03-10)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: TASK-159
|
||||
title: Add overlay controller support for keyboard-only mode
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-11 00:30'
|
||||
updated_date: '2026-03-11 04:05'
|
||||
labels:
|
||||
- enhancement
|
||||
- renderer
|
||||
- overlay
|
||||
- input
|
||||
dependencies:
|
||||
- TASK-86
|
||||
references:
|
||||
- src/renderer/handlers/keyboard.ts
|
||||
- src/renderer/renderer.ts
|
||||
- src/renderer/state.ts
|
||||
- src/renderer/index.html
|
||||
- src/renderer/style.css
|
||||
- src/preload.ts
|
||||
- src/types.ts
|
||||
- src/config/definitions/defaults-core.ts
|
||||
- src/config/definitions/options-core.ts
|
||||
- src/config/definitions/template-sections.ts
|
||||
- config.example.jsonc
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Add Chrome Gamepad API support to the visible overlay as a supplement to keyboard-only mode. By default SubMiner should bind to the first available controller, allow the user to pick and persist a preferred controller, expose a raw-input debug modal, and map controller actions onto the existing keyboard-only/Yomitan flow without breaking keyboard input. Also fix the current keyboard-only cleanup bug so the selected-token highlight clears when keyboard-only mode turns off or when the Yomitan popup closes.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 Controller input is ignored unless keyboard-only mode is enabled, except the controller binding for toggling keyboard-only mode itself.
|
||||
- [x] #2 Default logical mappings work: smooth popup scroll, token selection, lookup toggle/close, mining, Yomitan audio navigation/play, and mpv play/pause.
|
||||
- [x] #3 Controller config supports named logical bindings plus tuning knobs (preferred controller, deadzones, smooth-scroll speed/repeat), not raw axis/button maps.
|
||||
- [x] #4 `Alt+C` opens a controller selection modal listing connected controllers; saving a choice persists the preferred controller for next launch.
|
||||
- [x] #5 `Alt+Shift+C` opens a debug modal showing live raw controller axes/buttons as seen by SubMiner.
|
||||
- [x] #6 Keyboard-only selection highlight clears immediately when keyboard-only mode is disabled or the Yomitan popup closes.
|
||||
- [x] #7 Renderer/config regression tests cover controller gating, mappings, modal behavior, persisted selection, and highlight cleanup.
|
||||
- [x] #8 Docs/config example describe the controller feature and new shortcuts.
|
||||
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Added renderer-side gamepad polling and logical action mapping in `src/renderer/handlers/gamepad-controller.ts`.
|
||||
- Added controller select/debug modals, persisted preferred-controller IPC, and top-level `controller` config defaults/schema/template output.
|
||||
- Added a transient in-overlay controller status indicator when a controller is first detected.
|
||||
- Tuned controller defaults and routing after live testing: d-pad fallback navigation, slower repeat timing, DOM-backed popup-open detection, and direct pixel scroll/audio-source popup bridge commands.
|
||||
- Reused existing keyboard-only lookup/mining/navigation flows so controller input stays a supplement to keyboard-only mode instead of a parallel input path.
|
||||
- Verified keyboard-only highlight cleanup on mode-off and popup-close paths with renderer tests.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bun test src/config/config.test.ts src/config/definitions/domain-registry.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/handlers/gamepad-controller.test.ts src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts src/core/services/ipc.test.ts`
|
||||
- `bun test src/main/runtime/composers/ipc-runtime-composer.test.ts`
|
||||
- `bun run generate:config-example`
|
||||
- `bun run typecheck`
|
||||
- `bun run docs:test`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
- `bun run docs:build`
|
||||
- `bun run test:smoke:dist`
|
||||
7
changes/controller-overlay-support.md
Normal file
7
changes/controller-overlay-support.md
Normal file
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||
@@ -50,6 +50,55 @@
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// Controller Support
|
||||
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
// Use the selection modal to save a preferred controller by id for future launches.
|
||||
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||
// ==========================================
|
||||
"controller": {
|
||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
|
||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||
}, // Button indices setting.
|
||||
"bindings": {
|
||||
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
} // Bindings setting.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
// Startup Warmups
|
||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 (2026-03-12)
|
||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
||||
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
||||
- Added smooth, slower popup scrolling for controller navigation.
|
||||
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
|
||||
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.
|
||||
|
||||
## v0.5.6 (2026-03-10)
|
||||
- Persisted merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails.
|
||||
- Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of canonical `~/.config/SubMiner`.
|
||||
|
||||
@@ -95,6 +95,7 @@ The configuration file includes several main sections:
|
||||
|
||||
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
||||
- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode
|
||||
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
||||
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
||||
@@ -503,6 +504,88 @@ Set any shortcut to `null` to disable it.
|
||||
|
||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||
|
||||
### Controller Support
|
||||
|
||||
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
|
||||
|
||||
Important behavior:
|
||||
|
||||
- Controller input is only active while keyboard-only mode is enabled.
|
||||
- Keyboard-only mode continues to work normally without a controller.
|
||||
- By default SubMiner uses the first connected controller.
|
||||
- `Alt+C` opens the controller selection modal and saves the selected controller for future launches.
|
||||
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||
- 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.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"controller": {
|
||||
"enabled": true,
|
||||
"preferredGamepadId": "",
|
||||
"preferredGamepadLabel": "",
|
||||
"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": "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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Default logical mapping:
|
||||
|
||||
- Left stick up/down: scroll Yomitan popup
|
||||
- Left stick left/right: move subtitle token selection
|
||||
- Right stick up/down: page-jump through Yomitan popup
|
||||
- Right stick left/right: unused by default
|
||||
- `A`: toggle lookup
|
||||
- `B`: close lookup
|
||||
- `Y`: toggle keyboard-only mode
|
||||
- `X`: mine card
|
||||
- `Minus` / `Select`: quit mpv
|
||||
- `L1`: play current Yomitan audio (falls back to the first available track)
|
||||
- `R1`: move to the next available Yomitan audio track
|
||||
- `L3`: toggle mpv pause
|
||||
- `L2` / `R2`: unbound by 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.
|
||||
|
||||
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
|
||||
|
||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||
|
||||
@@ -59,6 +59,22 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
||||
3. Yomitan detects the selection and opens its lookup popup.
|
||||
4. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
|
||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
|
||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
|
||||
## Creating Anki Cards
|
||||
|
||||
There are three ways to create cards, depending on your workflow.
|
||||
|
||||
@@ -50,6 +50,55 @@
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// Controller Support
|
||||
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
// Use the selection modal to save a preferred controller by id for future launches.
|
||||
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||
// ==========================================
|
||||
"controller": {
|
||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
|
||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||
}, // Button indices setting.
|
||||
"bindings": {
|
||||
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
} // Bindings setting.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
// Startup Warmups
|
||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||
|
||||
@@ -69,6 +69,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
|
||||
## Controller Shortcuts
|
||||
|
||||
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
||||
|
||||
| Shortcut | Action | Configurable |
|
||||
| ------------- | ------------------------------ | ------------ |
|
||||
| `Alt+C` | Open controller selection 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.
|
||||
|
||||
## MPV Plugin Chords
|
||||
|
||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||
|
||||
@@ -246,6 +246,45 @@ Notes:
|
||||
- `--whisper-threads`
|
||||
- `--yt-subgen-audio-format`
|
||||
|
||||
## Controller Support
|
||||
|
||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Connect a controller before or after launching SubMiner.
|
||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||
3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup.
|
||||
4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||
|
||||
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.
|
||||
|
||||
### Default Button Mapping
|
||||
|
||||
| Button | Action |
|
||||
| ------ | ------ |
|
||||
| `A` (South) | Toggle lookup |
|
||||
| `B` (East) | Close lookup |
|
||||
| `Y` (North) | Toggle keyboard-only mode |
|
||||
| `X` (West) | Mine card |
|
||||
| `L1` | Play current Yomitan audio |
|
||||
| `R1` | Next Yomitan audio track |
|
||||
| `L3` (left stick press) | Toggle mpv pause |
|
||||
| `Select` / `Minus` | Quit mpv |
|
||||
| `L2` / `R2` | Unbound (available for custom bindings) |
|
||||
|
||||
### Analog Controls
|
||||
|
||||
| Input | Action |
|
||||
| ----- | ------ |
|
||||
| Left stick horizontal | Move token selection left/right |
|
||||
| Left stick vertical | Smooth scroll Yomitan popup |
|
||||
| Right stick horizontal | Jump inside popup (horizontal) |
|
||||
| Right stick vertical | Smooth scroll popup (vertical) |
|
||||
| D-pad | Fallback for stick navigation |
|
||||
|
||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||
|
||||
## Keybindings
|
||||
|
||||
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
||||
|
||||
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Overlay Controller Support Design
|
||||
|
||||
**Date:** 2026-03-11
|
||||
**Backlog:** `TASK-159`
|
||||
|
||||
## Goal
|
||||
|
||||
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
|
||||
|
||||
## Scope
|
||||
|
||||
- Poll connected gamepads from the visible overlay renderer.
|
||||
- Default to the first connected controller unless config specifies a preferred controller.
|
||||
- Add logical controller bindings and tuning knobs to config.
|
||||
- Add `Alt+C` controller selection modal.
|
||||
- Add `Alt+Shift+C` controller debug modal.
|
||||
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
|
||||
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
|
||||
|
||||
Out of scope for this pass:
|
||||
|
||||
- Raw arbitrary axis/button index remapping in config.
|
||||
- Controller support outside the visible overlay renderer.
|
||||
- Haptics or vibration.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
|
||||
|
||||
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
|
||||
|
||||
## Behavior
|
||||
|
||||
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
|
||||
|
||||
Default logical mappings:
|
||||
|
||||
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
|
||||
- left stick horizontal: move token selection left/right
|
||||
- right stick vertical: smooth Yomitan popup/window scroll
|
||||
- right stick horizontal: jump horizontally inside Yomitan popup/window
|
||||
- `A`: toggle lookup
|
||||
- `B`: close lookup
|
||||
- `Y`: toggle keyboard-only mode
|
||||
- `X`: mine card
|
||||
- `L1` / `R1`: previous / next Yomitan audio
|
||||
- `R2`: activate current Yomitan audio button
|
||||
- `L2`: toggle mpv play/pause
|
||||
|
||||
Selection-highlight cleanup:
|
||||
|
||||
- disabling keyboard-only mode clears the selected token class immediately
|
||||
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
|
||||
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
|
||||
|
||||
## Config
|
||||
|
||||
Add a top-level `controller` block in resolved config with:
|
||||
|
||||
- `enabled`
|
||||
- `preferredGamepadId`
|
||||
- `preferredGamepadLabel`
|
||||
- `smoothScroll`
|
||||
- `scrollPixelsPerSecond`
|
||||
- `horizontalJumpPixels`
|
||||
- `stickDeadzone`
|
||||
- `triggerDeadzone`
|
||||
- `repeatDelayMs`
|
||||
- `repeatIntervalMs`
|
||||
- `bindings` logical fields for the named actions/sticks
|
||||
|
||||
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
|
||||
|
||||
## UI
|
||||
|
||||
Controller selection modal:
|
||||
|
||||
- overlay-hosted modal in the visible renderer
|
||||
- lists currently connected controllers
|
||||
- highlights current active choice
|
||||
- selecting one persists config and makes it the active controller immediately if connected
|
||||
|
||||
Controller debug modal:
|
||||
|
||||
- overlay-hosted modal
|
||||
- shows selected controller and all connected controllers
|
||||
- live raw axis array values
|
||||
- live raw button values, pressed flags, and touched flags if available
|
||||
|
||||
## Testing
|
||||
|
||||
Test first:
|
||||
|
||||
- controller gating outside keyboard-only mode
|
||||
- logical mapping to existing helpers
|
||||
- continuous stick scroll and repeat behavior
|
||||
- modal open shortcuts
|
||||
- preferred-controller selection persistence
|
||||
- highlight cleanup on keyboard-only disable and popup close
|
||||
- config defaults/parse/template generation coverage
|
||||
|
||||
## Risks
|
||||
|
||||
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
|
||||
Mitigation: match by exact preferred id first; fall back to first connected controller.
|
||||
- Continuous stick input can spam actions.
|
||||
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
|
||||
- Popup DOM/audio controls may vary.
|
||||
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.
|
||||
|
||||
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Overlay Controller Support Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
|
||||
|
||||
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Track work and lock the design
|
||||
|
||||
**Files:**
|
||||
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
|
||||
|
||||
**Step 1: Record the approved scope**
|
||||
|
||||
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
|
||||
|
||||
**Step 2: Verify the written scope matches the approved design**
|
||||
|
||||
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
|
||||
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
|
||||
|
||||
### Task 2: Add failing config tests and defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/config.test.ts`
|
||||
- Modify: `src/config/definitions/defaults-core.ts`
|
||||
- Modify: `src/config/definitions/options-core.ts`
|
||||
- Modify: `src/config/definitions/template-sections.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `config.example.jsonc`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: FAIL because `controller` config is not defined yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Add failing keyboard-selection cleanup tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/handlers/keyboard.test.ts`
|
||||
- Modify: `src/renderer/handlers/keyboard.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- turning keyboard-only mode off clears `.keyboard-selected`
|
||||
- closing the popup clears stale selection highlight when keyboard-only mode is off
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: FAIL because selection cleanup is incomplete today.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Add failing controller runtime tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- Create: `src/renderer/handlers/gamepad-controller.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- first connected controller is selected by default
|
||||
- preferred controller wins when connected
|
||||
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
|
||||
- stick/button mappings invoke the expected logical helpers
|
||||
- smooth scroll and repeat throttling behavior
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: FAIL because controller runtime does not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 5: Add failing controller modal tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/index.html`
|
||||
- Modify: `src/renderer/style.css`
|
||||
- Create: `src/renderer/modals/controller-select.ts`
|
||||
- Create: `src/renderer/modals/controller-select.test.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.test.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `Alt+C` opens controller selection modal
|
||||
- `Alt+Shift+C` opens controller debug modal
|
||||
- selection modal renders connected controllers and persists the chosen device
|
||||
- debug modal shows live axes/buttons state
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: FAIL because modals and shortcuts do not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 6: Persist controller preference through preload/main wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/preload.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `src/shared/ipc/contracts.ts`
|
||||
- Modify: `src/core/services/ipc.ts`
|
||||
- Modify: `src/main.ts`
|
||||
- Modify: related main/runtime tests as needed
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: FAIL because no controller preference IPC exists yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 7: Update docs and config example
|
||||
|
||||
**Files:**
|
||||
- Modify: `config.example.jsonc`
|
||||
- Modify: `README.md`
|
||||
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
|
||||
|
||||
**Step 1: Write the failing doc/config check if needed**
|
||||
|
||||
If config example generation is covered by tests, add/refresh the failing assertion first.
|
||||
|
||||
**Step 2: Implement the docs**
|
||||
|
||||
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
|
||||
|
||||
**Step 3: Run doc/config verification**
|
||||
|
||||
Run: `bun run test:config`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 8: Run the handoff gate and update the backlog task
|
||||
|
||||
**Files:**
|
||||
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
|
||||
**Step 1: Run targeted verification**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun test src/config/config.test.ts`
|
||||
- `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- `bun test src/renderer/modals/controller-select.test.ts`
|
||||
- `bun test src/renderer/modals/controller-debug.test.ts`
|
||||
- `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run broader gate**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
|
||||
Expected: PASS, or document exact blockers/failures.
|
||||
|
||||
**Step 3: Update backlog notes**
|
||||
|
||||
Fill in implementation notes, verification commands, and final summary in `TASK-159`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -1106,6 +1106,135 @@ test('parses global shortcuts and startup settings', () => {
|
||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
||||
});
|
||||
|
||||
test('parses controller settings with logical bindings and tuning knobs', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"enabled": true,
|
||||
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
|
||||
"preferredGamepadLabel": "Xbox Wireless Controller",
|
||||
"smoothScroll": false,
|
||||
"scrollPixelsPerSecond": 1440,
|
||||
"horizontalJumpPixels": 180,
|
||||
"stickDeadzone": 0.3,
|
||||
"triggerInputMode": "analog",
|
||||
"triggerDeadzone": 0.4,
|
||||
"repeatDelayMs": 220,
|
||||
"repeatIntervalMs": 70,
|
||||
"buttonIndices": {
|
||||
"select": 6,
|
||||
"leftStickPress": 9,
|
||||
"rightStickPress": 10
|
||||
},
|
||||
"bindings": {
|
||||
"toggleLookup": "buttonWest",
|
||||
"closeLookup": "buttonEast",
|
||||
"toggleKeyboardOnlyMode": "buttonNorth",
|
||||
"mineCard": "buttonSouth",
|
||||
"quitMpv": "select",
|
||||
"previousAudio": "leftShoulder",
|
||||
"nextAudio": "rightShoulder",
|
||||
"playCurrentAudio": "none",
|
||||
"toggleMpvPause": "leftStickPress",
|
||||
"leftStickHorizontal": "rightStickX",
|
||||
"leftStickVertical": "rightStickY",
|
||||
"rightStickHorizontal": "leftStickX",
|
||||
"rightStickVertical": "leftStickY"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.controller.enabled, true);
|
||||
assert.equal(
|
||||
config.controller.preferredGamepadId,
|
||||
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
|
||||
);
|
||||
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
|
||||
assert.equal(config.controller.smoothScroll, false);
|
||||
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
|
||||
assert.equal(config.controller.horizontalJumpPixels, 180);
|
||||
assert.equal(config.controller.stickDeadzone, 0.3);
|
||||
assert.equal(config.controller.triggerInputMode, 'analog');
|
||||
assert.equal(config.controller.triggerDeadzone, 0.4);
|
||||
assert.equal(config.controller.repeatDelayMs, 220);
|
||||
assert.equal(config.controller.repeatIntervalMs, 70);
|
||||
assert.equal(config.controller.buttonIndices.select, 6);
|
||||
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
|
||||
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest');
|
||||
assert.equal(config.controller.bindings.quitMpv, 'select');
|
||||
assert.equal(config.controller.bindings.playCurrentAudio, 'none');
|
||||
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress');
|
||||
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX');
|
||||
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
|
||||
});
|
||||
|
||||
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"scrollPixelsPerSecond": 0.5,
|
||||
"horizontalJumpPixels": 0.2,
|
||||
"repeatDelayMs": 0.9,
|
||||
"repeatIntervalMs": 0.1
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
|
||||
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
|
||||
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
|
||||
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
|
||||
});
|
||||
|
||||
test('controller button index config rejects fractional values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"buttonIndices": {
|
||||
"select": 6.5,
|
||||
"leftStickPress": 9.1
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
|
||||
assert.equal(
|
||||
config.controller.buttonIndices.leftStickPress,
|
||||
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
|
||||
);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
@@ -1638,6 +1767,7 @@ test('template generator includes known keys', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
assert.match(output, /"ai":/);
|
||||
assert.match(output, /"ankiConnect":/);
|
||||
assert.match(output, /"controller":/);
|
||||
assert.match(output, /"logging":/);
|
||||
assert.match(output, /"websocket":/);
|
||||
assert.match(output, /"discordPresence":/);
|
||||
@@ -1662,6 +1792,14 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
controller,
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
subsync,
|
||||
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
controller,
|
||||
ankiConnect,
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
|
||||
@@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'annotationWebsocket'
|
||||
| 'logging'
|
||||
| 'texthooker'
|
||||
| 'controller'
|
||||
| 'shortcuts'
|
||||
| 'secondarySub'
|
||||
| 'subsync'
|
||||
@@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
launchAtStartup: true,
|
||||
openBrowser: true,
|
||||
},
|
||||
controller: {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
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: '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',
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||
copySubtitle: 'CommandOrControl+C',
|
||||
|
||||
@@ -19,6 +19,8 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'annotationWebsocket.enabled',
|
||||
'controller.enabled',
|
||||
'controller.scrollPixelsPerSecond',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
@@ -38,6 +40,7 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'controller',
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
|
||||
@@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared';
|
||||
export function buildCoreConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
const controllerButtonEnumValues = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'logging.level',
|
||||
@@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.logging.level,
|
||||
description: 'Minimum log level for runtime logging.',
|
||||
},
|
||||
{
|
||||
path: 'controller.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.controller.enabled,
|
||||
description: 'Enable overlay controller support through the Chrome Gamepad API.',
|
||||
},
|
||||
{
|
||||
path: 'controller.preferredGamepadId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.controller.preferredGamepadId,
|
||||
description: 'Preferred controller id saved from the controller selection modal.',
|
||||
},
|
||||
{
|
||||
path: 'controller.preferredGamepadLabel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.controller.preferredGamepadLabel,
|
||||
description: 'Preferred controller display label saved for diagnostics.',
|
||||
},
|
||||
{
|
||||
path: 'controller.smoothScroll',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.controller.smoothScroll,
|
||||
description: 'Use smooth scrolling for controller-driven popup scroll input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.scrollPixelsPerSecond',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.scrollPixelsPerSecond,
|
||||
description: 'Base popup scroll speed for controller stick input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.horizontalJumpPixels',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.horizontalJumpPixels,
|
||||
description: 'Popup page-jump distance for controller jump input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.stickDeadzone',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.stickDeadzone,
|
||||
description: 'Deadzone applied to controller stick axes.',
|
||||
},
|
||||
{
|
||||
path: 'controller.triggerInputMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'digital', 'analog'],
|
||||
defaultValue: defaultConfig.controller.triggerInputMode,
|
||||
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
||||
},
|
||||
{
|
||||
path: 'controller.triggerDeadzone',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.triggerDeadzone,
|
||||
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
|
||||
},
|
||||
{
|
||||
path: 'controller.repeatDelayMs',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.repeatDelayMs,
|
||||
description: 'Delay before repeating held controller actions.',
|
||||
},
|
||||
{
|
||||
path: 'controller.repeatIntervalMs',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.repeatIntervalMs,
|
||||
description: 'Repeat interval for held controller actions.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.select',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.select,
|
||||
description: 'Raw button index used for the controller select/minus/back button.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonSouth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonSouth,
|
||||
description: 'Raw button index used for controller south/A button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonEast',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonEast,
|
||||
description: 'Raw button index used for controller east/B button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonNorth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonNorth,
|
||||
description: 'Raw button index used for controller north/Y button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.buttonWest',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.buttonWest,
|
||||
description: 'Raw button index used for controller west/X button input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.leftShoulder',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.leftShoulder,
|
||||
description: 'Raw button index used for controller left shoulder input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.rightShoulder',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.rightShoulder,
|
||||
description: 'Raw button index used for controller right shoulder input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.leftStickPress',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.leftStickPress,
|
||||
description: 'Raw button index used for controller L3 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.rightStickPress',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.rightStickPress,
|
||||
description: 'Raw button index used for controller R3 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.leftTrigger',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.leftTrigger,
|
||||
description: 'Raw button index used for controller L2 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.rightTrigger',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
|
||||
description: 'Raw button index used for controller R2 input.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.toggleLookup',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.toggleLookup,
|
||||
description: 'Controller binding for toggling lookup.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.closeLookup',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.closeLookup,
|
||||
description: 'Controller binding for closing lookup.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.toggleKeyboardOnlyMode',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
|
||||
description: 'Controller binding for toggling keyboard-only mode.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.mineCard',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.mineCard,
|
||||
description: 'Controller binding for mining the active card.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.quitMpv',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.quitMpv,
|
||||
description: 'Controller binding for quitting mpv.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.previousAudio',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.previousAudio,
|
||||
description: 'Controller binding for previous Yomitan audio.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.nextAudio',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.nextAudio,
|
||||
description: 'Controller binding for next Yomitan audio.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.playCurrentAudio',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
|
||||
description: 'Controller binding for playing the current Yomitan audio.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.toggleMpvPause',
|
||||
kind: 'enum',
|
||||
enumValues: controllerButtonEnumValues,
|
||||
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
||||
description: 'Controller binding for toggling mpv play/pause.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.leftStickHorizontal',
|
||||
kind: 'enum',
|
||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
|
||||
description: 'Axis binding used for left/right token selection.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.leftStickVertical',
|
||||
kind: 'enum',
|
||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
||||
description: 'Axis binding used for primary popup scrolling.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.rightStickHorizontal',
|
||||
kind: 'enum',
|
||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
||||
description: 'Axis binding reserved for alternate right-stick mappings.',
|
||||
},
|
||||
{
|
||||
path: 'controller.bindings.rightStickVertical',
|
||||
kind: 'enum',
|
||||
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
||||
description: 'Axis binding used for popup page jumps.',
|
||||
},
|
||||
{
|
||||
path: 'texthooker.launchAtStartup',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
key: 'logging',
|
||||
},
|
||||
{
|
||||
title: 'Controller Support',
|
||||
description: [
|
||||
'Gamepad support for the visible overlay while keyboard-only mode is active.',
|
||||
'Use the selection modal to save a preferred controller by id for future launches.',
|
||||
'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.',
|
||||
],
|
||||
key: 'controller',
|
||||
},
|
||||
{
|
||||
title: 'Startup Warmups',
|
||||
description: [
|
||||
|
||||
@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
const controllerButtonBindings = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
@@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller)) {
|
||||
const enabled = asBoolean(src.controller.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.controller.enabled = enabled;
|
||||
} else if (src.controller.enabled !== undefined) {
|
||||
warn(
|
||||
'controller.enabled',
|
||||
src.controller.enabled,
|
||||
resolved.controller.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||
if (preferredGamepadId !== undefined) {
|
||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||
}
|
||||
|
||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||
if (preferredGamepadLabel !== undefined) {
|
||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||
}
|
||||
|
||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||
if (smoothScroll !== undefined) {
|
||||
resolved.controller.smoothScroll = smoothScroll;
|
||||
} else if (src.controller.smoothScroll !== undefined) {
|
||||
warn(
|
||||
'controller.smoothScroll',
|
||||
src.controller.smoothScroll,
|
||||
resolved.controller.smoothScroll,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||
if (
|
||||
triggerInputMode === 'auto' ||
|
||||
triggerInputMode === 'digital' ||
|
||||
triggerInputMode === 'analog'
|
||||
) {
|
||||
resolved.controller.triggerInputMode = triggerInputMode;
|
||||
} else if (src.controller.triggerInputMode !== undefined) {
|
||||
warn(
|
||||
'controller.triggerInputMode',
|
||||
src.controller.triggerInputMode,
|
||||
resolved.controller.triggerInputMode,
|
||||
"Expected 'auto', 'digital', or 'analog'.",
|
||||
);
|
||||
}
|
||||
|
||||
const boundedNumberKeys = [
|
||||
'scrollPixelsPerSecond',
|
||||
'horizontalJumpPixels',
|
||||
'repeatDelayMs',
|
||||
'repeatIntervalMs',
|
||||
] as const;
|
||||
for (const key of boundedNumberKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && Math.floor(value) > 0) {
|
||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
|
||||
}
|
||||
}
|
||||
|
||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||
for (const key of deadzoneKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && value >= 0 && value <= 1) {
|
||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.buttonIndices)) {
|
||||
const buttonIndexKeys = [
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonIndexKeys) {
|
||||
const value = asNumber(src.controller.buttonIndices[key]);
|
||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||
resolved.controller.buttonIndices[key] = value;
|
||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||
warn(
|
||||
`controller.buttonIndices.${key}`,
|
||||
src.controller.buttonIndices[key],
|
||||
resolved.controller.buttonIndices[key],
|
||||
'Expected non-negative integer.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.bindings)) {
|
||||
const buttonBindingKeys = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const axisBindingKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
for (const key of axisBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
||||
|
||||
@@ -53,6 +53,48 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
@@ -117,6 +159,48 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||
assert.ok(getPlaybackPausedHandler);
|
||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
||||
|
||||
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
|
||||
assert.ok(getControllerConfigHandler);
|
||||
assert.equal(
|
||||
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
|
||||
960,
|
||||
);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
const controllerSaves: unknown[] = [];
|
||||
const closedModals: unknown[] = [];
|
||||
const openedModals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
@@ -207,6 +299,50 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const controllerSaves: 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: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: async (update) => {
|
||||
await Promise.resolve();
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
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.saveControllerPreference);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerSaves, [
|
||||
{
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
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: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
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.saveControllerPreference);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import electron from 'electron';
|
||||
import type { IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
@@ -10,6 +12,7 @@ import type {
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
parseControllerPreferenceUpdate,
|
||||
parseOptionalForwardingOptions,
|
||||
parseOverlayHostedModal,
|
||||
parseRuntimeOptionDirection,
|
||||
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
handleMpvCommand: options.handleMpvCommand,
|
||||
getKeybindings: options.getKeybindings,
|
||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
getSecondarySubMode: options.getSecondarySubMode,
|
||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||
focusMainWindow: () => {
|
||||
@@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.saveSubtitlePosition(parsedPosition);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
|
||||
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller preference payload');
|
||||
}
|
||||
await deps.saveControllerPreference(parsedUpdate);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
return deps.getMecabStatus();
|
||||
});
|
||||
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getConfiguredShortcuts();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||
return deps.getSecondarySubMode();
|
||||
});
|
||||
|
||||
12
src/main.ts
12
src/main.ts
@@ -358,7 +358,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import type { OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
@@ -3407,6 +3408,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getControllerConfig: () => getResolvedConfig().controller,
|
||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||
configService.patchRawConfig({
|
||||
controller: {
|
||||
preferredGamepadId,
|
||||
preferredGamepadLabel,
|
||||
},
|
||||
});
|
||||
},
|
||||
getSecondarySubMode: () => appState.secondarySubMode,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||
@@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
getMpvClient: params.getMpvClient,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
|
||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService(
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
export type { OverlayHostedModal };
|
||||
|
||||
@@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover' as never,
|
||||
getMpvClient: () => null,
|
||||
getAnkiConnectStatus: () => false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
||||
|
||||
export function createSetOverlayVisibleHandler(deps: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeOptionState } from '../../types';
|
||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
|
||||
type RuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
|
||||
|
||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
notifyOverlayModalClosed: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
notifyOverlayModalOpened: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||
},
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
|
||||
107
src/renderer/controller-status-indicator.test.ts
Normal file
107
src/renderer/controller-status-indicator.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createControllerStatusIndicator } from './controller-status-indicator.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);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
|
||||
let nextTimerId = 1;
|
||||
const scheduled = new Map<number, () => void>();
|
||||
const classList = createClassList(['hidden']);
|
||||
const toast = {
|
||||
textContent: '',
|
||||
classList,
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
durationMs: 1500,
|
||||
setTimeout: (callback: () => void) => {
|
||||
const id = nextTimerId++;
|
||||
scheduled.set(id, callback);
|
||||
return id as never;
|
||||
},
|
||||
clearTimeout: (id) => {
|
||||
scheduled.delete(id as never as number);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
});
|
||||
|
||||
assert.equal(classList.contains('hidden'), true);
|
||||
assert.equal(toast.textContent, '');
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(classList.contains('hidden'), false);
|
||||
assert.match(toast.textContent, /controller detected/i);
|
||||
assert.match(toast.textContent, /pad-1/i);
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
const [hide] = scheduled.values();
|
||||
hide?.();
|
||||
|
||||
assert.equal(classList.contains('hidden'), true);
|
||||
assert.equal(toast.textContent, '');
|
||||
});
|
||||
|
||||
test('controller status indicator announces newly detected controllers after startup', () => {
|
||||
const toast = {
|
||||
textContent: '',
|
||||
classList: createClassList(['hidden']),
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
toast.classList.add('hidden');
|
||||
toast.textContent = '';
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(toast.classList.contains('hidden'), false);
|
||||
assert.match(toast.textContent, /pad-2/i);
|
||||
});
|
||||
69
src/renderer/controller-status-indicator.ts
Normal file
69
src/renderer/controller-status-indicator.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ControllerDeviceInfo } from '../types';
|
||||
|
||||
type ControllerSnapshot = {
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
};
|
||||
|
||||
type ControllerStatusIndicatorOptions = {
|
||||
durationMs?: number;
|
||||
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
|
||||
};
|
||||
|
||||
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
|
||||
if (!device) return 'Controller';
|
||||
return device.id || `Gamepad ${device.index}`;
|
||||
}
|
||||
|
||||
export function createControllerStatusIndicator(
|
||||
dom: {
|
||||
controllerStatusToast: {
|
||||
textContent: string;
|
||||
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
|
||||
};
|
||||
},
|
||||
options: ControllerStatusIndicatorOptions = {},
|
||||
) {
|
||||
const durationMs = options.durationMs ?? 2200;
|
||||
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
|
||||
const cancelTimeout =
|
||||
options.clearTimeout ??
|
||||
((timer: ReturnType<typeof setTimeout> | number) =>
|
||||
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
|
||||
let previousConnectedIds = new Set<string>();
|
||||
|
||||
function show(message: string): void {
|
||||
if (hideTimeout !== null) {
|
||||
cancelTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
dom.controllerStatusToast.textContent = message;
|
||||
dom.controllerStatusToast.classList.remove('hidden');
|
||||
hideTimeout = scheduleTimeout(() => {
|
||||
dom.controllerStatusToast.classList.add('hidden');
|
||||
dom.controllerStatusToast.textContent = '';
|
||||
hideTimeout = null;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
function update(snapshot: ControllerSnapshot): void {
|
||||
const newDevices = snapshot.connectedGamepads.filter(
|
||||
(device) => !previousConnectedIds.has(device.id),
|
||||
);
|
||||
if (newDevices.length > 0) {
|
||||
const activeDevice = snapshot.connectedGamepads.find(
|
||||
(device) => device.id === snapshot.activeGamepadId,
|
||||
);
|
||||
const announcedDevice =
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
|
||||
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
||||
}
|
||||
|
||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import { createGamepadController } from './gamepad-controller.js';
|
||||
|
||||
type TestGamepad = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
axes: number[];
|
||||
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
||||
};
|
||||
|
||||
function createGamepad(
|
||||
id: string,
|
||||
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
|
||||
): TestGamepad {
|
||||
return {
|
||||
id,
|
||||
index: options.index ?? 0,
|
||||
connected: true,
|
||||
mapping: 'standard',
|
||||
axes: options.axes ?? [0, 0, 0, 0],
|
||||
buttons:
|
||||
options.buttons ??
|
||||
Array.from({ length: 16 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createControllerConfig(
|
||||
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
|
||||
bindings?: Partial<ResolvedControllerConfig['bindings']>;
|
||||
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
||||
} = {},
|
||||
): ResolvedControllerConfig {
|
||||
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
|
||||
overrides;
|
||||
return {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
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,
|
||||
...(buttonIndexOverrides ?? {}),
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
...(bindingOverrides ?? {}),
|
||||
},
|
||||
...restOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('gamepad controller selects the first connected controller by default', () => {
|
||||
const updates: string[] = [];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: (state) => {
|
||||
updates.push(state.activeGamepadId ?? 'none');
|
||||
},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||
assert.deepEqual(updates.at(-1), 'pad-2');
|
||||
});
|
||||
|
||||
test('gamepad controller prefers saved controller id when connected', () => {
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
|
||||
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||
});
|
||||
|
||||
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
|
||||
const calls: string[] = [];
|
||||
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: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||
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 does not toggle keyboard mode when controller support is disabled', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig({ enabled: false }),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
|
||||
const calls: string[] = [];
|
||||
const selectionCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
let axes = [0.9, 0, 0, 0];
|
||||
let keyboardModeEnabled = true;
|
||||
let interactionBlocked = true;
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => interactionBlocked,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => calls.push('toggle-lookup'),
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
interactionBlocked = false;
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(selectionCalls, []);
|
||||
|
||||
buttons[0] = { value: 0, pressed: false, touched: false };
|
||||
axes = [0, 0, 0, 0];
|
||||
controller.poll(200);
|
||||
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
axes = [0.9, 0, 0, 0];
|
||||
controller.poll(300);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-lookup']);
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
||||
const calls: number[] = [];
|
||||
let axes = [0.9, 0, 0, 0];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => calls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
controller.poll(260);
|
||||
|
||||
assert.deepEqual(calls, [1]);
|
||||
|
||||
controller.poll(340);
|
||||
|
||||
assert.deepEqual(calls, [1, 1]);
|
||||
|
||||
axes = [0, 0, 0, 0];
|
||||
controller.poll(360);
|
||||
axes = [-0.9, 0, 0, 0];
|
||||
controller.poll(380);
|
||||
|
||||
assert.deepEqual(calls, [1, 1, -1]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
||||
const calls: string[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[8] = { value: 1, pressed: true, touched: true };
|
||||
buttons[4] = { value: 1, pressed: true, touched: true };
|
||||
buttons[5] = { value: 1, pressed: true, touched: true };
|
||||
buttons[6] = { value: 0.8, pressed: true, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () =>
|
||||
[
|
||||
createGamepad('pad-1', {
|
||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||
buttons,
|
||||
}),
|
||||
],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
previousAudio: 'none',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => calls.push('prev-audio'),
|
||||
nextAudio: () => calls.push('next-audio'),
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.equal(calls.includes('next-audio'), true);
|
||||
assert.equal(calls.includes('play-audio'), true);
|
||||
assert.equal(calls.includes('prev-audio'), false);
|
||||
assert.equal(calls.includes('toggle-mpv-pause'), true);
|
||||
assert.equal(calls.includes('quit-mpv'), true);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
|
||||
assert.equal(calls.includes('jump:160'), true);
|
||||
});
|
||||
|
||||
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['quit-mpv']);
|
||||
});
|
||||
|
||||
test('gamepad controller honors configured raw button index overrides', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[11] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
buttonIndices: {
|
||||
select: 11,
|
||||
},
|
||||
bindings: { quitMpv: 'select' },
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['quit-mpv']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
|
||||
const calls: string[] = [];
|
||||
let axes = [0, 0, 0.85, 0, 0];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
axes = [0, 0, 0.85, 0, -0.85];
|
||||
controller.poll(200);
|
||||
|
||||
assert.deepEqual(calls, ['jump:-160']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
|
||||
const selectionCalls: number[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[15] = { value: 1, pressed: false, touched: true };
|
||||
buttons[12] = { value: 1, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
||||
const selectionCalls: number[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.7, pressed: false, touched: true };
|
||||
buttons[7] = { value: 0.8, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'analog',
|
||||
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, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger digital mode uses pressed state only', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.9, pressed: true, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'digital',
|
||||
triggerDeadzone: 1,
|
||||
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, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[9] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
playCurrentAudio: 'none',
|
||||
},
|
||||
}),
|
||||
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, ['toggle-mpv-pause']);
|
||||
});
|
||||
571
src/renderer/handlers/gamepad-controller.ts
Normal file
571
src/renderer/handlers/gamepad-controller.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerButtonBinding,
|
||||
ControllerDeviceInfo,
|
||||
ControllerRuntimeSnapshot,
|
||||
ControllerTriggerInputMode,
|
||||
ResolvedControllerConfig,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
pressed?: boolean;
|
||||
touched?: boolean;
|
||||
};
|
||||
|
||||
type GamepadLike = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
|
||||
type GamepadControllerOptions = {
|
||||
getGamepads: () => Array<GamepadLike | null>;
|
||||
getConfig: () => ResolvedControllerConfig;
|
||||
getKeyboardModeEnabled: () => boolean;
|
||||
getLookupWindowOpen: () => boolean;
|
||||
getInteractionBlocked: () => boolean;
|
||||
toggleKeyboardMode: () => void;
|
||||
toggleLookup: () => void;
|
||||
closeLookup: () => void;
|
||||
moveSelection: (delta: -1 | 1) => void;
|
||||
mineCard: () => void;
|
||||
quitMpv: () => void;
|
||||
previousAudio: () => void;
|
||||
nextAudio: () => void;
|
||||
playCurrentAudio: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
scrollPopup: (deltaPixels: number) => void;
|
||||
jumpPopup: (deltaPixels: number) => void;
|
||||
onState: (state: ControllerRuntimeSnapshot) => void;
|
||||
};
|
||||
|
||||
type HoldState = {
|
||||
repeatStarted: boolean;
|
||||
direction: -1 | 1 | null;
|
||||
lastFireAt: number;
|
||||
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 = {
|
||||
up: 12,
|
||||
down: 13,
|
||||
left: 14,
|
||||
right: 15,
|
||||
} as const;
|
||||
const DPAD_AXIS_INDEX = {
|
||||
horizontal: 6,
|
||||
vertical: 7,
|
||||
} as const;
|
||||
|
||||
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
|
||||
return binding === 'leftTrigger' || binding === 'rightTrigger';
|
||||
}
|
||||
|
||||
function resolveButtonIndex(
|
||||
config: ResolvedControllerConfig,
|
||||
binding: ControllerButtonBinding,
|
||||
): number {
|
||||
if (binding === 'none') {
|
||||
return -1;
|
||||
}
|
||||
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
|
||||
}
|
||||
|
||||
function normalizeButtonState(
|
||||
gamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
binding: ControllerButtonBinding,
|
||||
triggerInputMode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (binding === 'none') {
|
||||
return false;
|
||||
}
|
||||
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
|
||||
if (isTriggerBinding(binding)) {
|
||||
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
|
||||
}
|
||||
return normalizeRawButtonState(button, triggerDeadzone);
|
||||
}
|
||||
|
||||
function normalizeRawButtonState(
|
||||
button: ControllerButtonState | undefined,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function normalizeTriggerState(
|
||||
button: ControllerButtonState | undefined,
|
||||
mode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
if (mode === 'digital') {
|
||||
return Boolean(button.pressed);
|
||||
}
|
||||
if (mode === 'analog') {
|
||||
return button.value >= triggerDeadzone;
|
||||
}
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
|
||||
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
|
||||
}
|
||||
|
||||
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
||||
const value = gamepad.axes[axisIndex];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function resolveDpadValue(
|
||||
gamepad: GamepadLike,
|
||||
negativeIndex: number,
|
||||
positiveIndex: number,
|
||||
triggerDeadzone: number,
|
||||
): number {
|
||||
const negative = gamepad.buttons[negativeIndex];
|
||||
const positive = gamepad.buttons[positiveIndex];
|
||||
return (
|
||||
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
|
||||
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadAxisValue(
|
||||
gamepad: GamepadLike,
|
||||
axisIndex: number,
|
||||
): number {
|
||||
const value = resolveGamepadAxis(gamepad, axisIndex);
|
||||
if (Math.abs(value) < 0.5) {
|
||||
return 0;
|
||||
}
|
||||
return Math.sign(value);
|
||||
}
|
||||
|
||||
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
|
||||
return gamepads
|
||||
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
|
||||
.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
function createHoldState(): HoldState {
|
||||
return {
|
||||
repeatStarted: false,
|
||||
direction: null,
|
||||
lastFireAt: 0,
|
||||
initialFired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
|
||||
if (!state.initialFired) {
|
||||
state.initialFired = true;
|
||||
state.lastFireAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
const elapsed = now - state.lastFireAt;
|
||||
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
|
||||
if (elapsed < threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.repeatStarted = true;
|
||||
state.lastFireAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetHeldAction(state: HoldState): void {
|
||||
state.repeatStarted = false;
|
||||
state.direction = null;
|
||||
state.lastFireAt = 0;
|
||||
state.initialFired = false;
|
||||
}
|
||||
|
||||
function syncHeldActionBlocked(
|
||||
state: HoldState,
|
||||
value: number,
|
||||
now: number,
|
||||
activationThreshold: number,
|
||||
): void {
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
state.repeatStarted = false;
|
||||
state.direction = direction;
|
||||
state.lastFireAt = now;
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
let jumpHold = createHoldState();
|
||||
let activeGamepadId: string | null = null;
|
||||
let lastPollAt: number | null = null;
|
||||
|
||||
function getConnectedGamepads(): GamepadLike[] {
|
||||
return resolveConnectedGamepads(options.getGamepads());
|
||||
}
|
||||
|
||||
function resolveActiveGamepad(
|
||||
gamepads: GamepadLike[],
|
||||
config: ResolvedControllerConfig,
|
||||
): GamepadLike | null {
|
||||
if (gamepads.length === 0) return null;
|
||||
if (config.preferredGamepadId.trim().length > 0) {
|
||||
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
}
|
||||
return gamepads[0] ?? null;
|
||||
}
|
||||
|
||||
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
|
||||
activeGamepadId = activeGamepad?.id ?? null;
|
||||
options.onState({
|
||||
connectedGamepads: gamepads.map((gamepad) => ({
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
mapping: gamepad.mapping,
|
||||
connected: gamepad.connected,
|
||||
})) satisfies ControllerDeviceInfo[],
|
||||
activeGamepadId,
|
||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||
rawButtons: activeGamepad?.buttons
|
||||
? activeGamepad.buttons.map((button) => ({
|
||||
value: button.value,
|
||||
pressed: Boolean(button.pressed),
|
||||
touched: button.touched,
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonEdge(
|
||||
binding: ControllerButtonBinding,
|
||||
isPressed: boolean,
|
||||
action: () => void,
|
||||
): void {
|
||||
if (binding === 'none') {
|
||||
return;
|
||||
}
|
||||
const wasPressed = previousButtons.get(binding) ?? false;
|
||||
previousButtons.set(binding, isPressed);
|
||||
if (!wasPressed && isPressed) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(selectionHold);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
if (selectionHold.direction !== direction) {
|
||||
resetHeldAction(selectionHold);
|
||||
selectionHold.direction = direction;
|
||||
}
|
||||
|
||||
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||
options.moveSelection(direction);
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(jumpHold);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
if (jumpHold.direction !== direction) {
|
||||
resetHeldAction(jumpHold);
|
||||
jumpHold.direction = direction;
|
||||
}
|
||||
|
||||
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||
options.jumpPopup(direction * config.horizontalJumpPixels);
|
||||
}
|
||||
}
|
||||
|
||||
function syncBlockedInteractionState(
|
||||
activeGamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
now: number,
|
||||
): void {
|
||||
const buttonBindings = new Set<ControllerButtonBinding>([
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.bindings.toggleLookup,
|
||||
config.bindings.closeLookup,
|
||||
config.bindings.mineCard,
|
||||
config.bindings.quitMpv,
|
||||
config.bindings.previousAudio,
|
||||
config.bindings.nextAudio,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.bindings.toggleMpvPause,
|
||||
]);
|
||||
|
||||
for (const binding of buttonBindings) {
|
||||
if (binding === 'none') continue;
|
||||
previousButtons.set(
|
||||
binding,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
binding,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const selectionValue = (() => {
|
||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||
})();
|
||||
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
syncHeldActionBlocked(
|
||||
jumpHold,
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
now,
|
||||
Math.max(config.stickDeadzone, 0.55),
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
}
|
||||
|
||||
function poll(now: number): void {
|
||||
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
||||
lastPollAt = now;
|
||||
const config = options.getConfig();
|
||||
const connectedGamepads = getConnectedGamepads();
|
||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||
publishState(connectedGamepads, activeGamepad);
|
||||
|
||||
if (!activeGamepad) {
|
||||
previousButtons = new Map();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
lastPollAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionAllowed =
|
||||
config.enabled &&
|
||||
options.getKeyboardModeEnabled() &&
|
||||
!options.getInteractionBlocked();
|
||||
if (config.enabled) {
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleKeyboardMode,
|
||||
);
|
||||
}
|
||||
if (!interactionAllowed) {
|
||||
syncBlockedInteractionState(activeGamepad, config, now);
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.closeLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.closeLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.closeLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.mineCard,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.mineCard,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.mineCard,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.quitMpv,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.quitMpv,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.quitMpv,
|
||||
);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
handleButtonEdge(
|
||||
config.bindings.previousAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.previousAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.previousAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.nextAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.nextAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.nextAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.playCurrentAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.playCurrentAudio,
|
||||
);
|
||||
|
||||
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
|
||||
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
|
||||
if (elapsedMs > 0) {
|
||||
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
if (dpadVertical !== 0) {
|
||||
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleMpvPause,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleMpvPause,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
(() => {
|
||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||
})(),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
poll,
|
||||
getActiveGamepadId: (): string | null => activeGamepadId,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||
import {
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
type?: string;
|
||||
@@ -11,6 +14,9 @@ type CommandEventDetail = {
|
||||
key?: string;
|
||||
code?: string;
|
||||
repeat?: boolean;
|
||||
direction?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
};
|
||||
|
||||
function createClassList() {
|
||||
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
|
||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
|
||||
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
|
||||
};
|
||||
|
||||
const selection = {
|
||||
removeAllRanges: () => {},
|
||||
addRange: () => {},
|
||||
removeAllRanges: () => {
|
||||
selectionClearCount += 1;
|
||||
},
|
||||
addRange: () => {
|
||||
selectionAddCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: () => {},
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const listeners = windowListeners.get(type) ?? [];
|
||||
listeners.push(listener);
|
||||
windowListeners.set(type, listeners);
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||
commandEvents.push(detail ?? {});
|
||||
}
|
||||
const listeners = windowListeners.get(event.type) ?? [];
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
@@ -192,6 +213,13 @@ function installKeyboardTestGlobals() {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchWindowEvent(type: string): void {
|
||||
const listeners = windowListeners.get(type) ?? [];
|
||||
for (const listener of listeners) {
|
||||
listener(new Event(type));
|
||||
}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
|
||||
windowFocusCalls: () => windowFocusCalls,
|
||||
dispatchKeydown,
|
||||
dispatchFocusInOnPopup,
|
||||
dispatchWindowEvent,
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
},
|
||||
selectionClearCount: () => selectionClearCount,
|
||||
selectionAddCount: () => selectionAddCount,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
|
||||
function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
let controllerSelectOpenCount = 0;
|
||||
let controllerDebugOpenCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleControllerSelectKeydown: () => {
|
||||
controllerSelectKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
handleControllerDebugKeydown: () => false,
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectOpenCount += 1;
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugOpenCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
assert.equal(handlers.playCurrentAudioForController(), true);
|
||||
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
|
||||
assert.equal(handlers.scrollPopupByController(48, -24), true);
|
||||
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.slice(-3),
|
||||
[
|
||||
{ type: 'playCurrentAudio' },
|
||||
{ type: 'cycleAudioSource', direction: 1 },
|
||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
||||
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
assert.equal(controllerSelectKeydownCount(), 1);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some(
|
||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: turning mode off clears selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(false);
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
||||
assert.equal(testGlobals.selectionAddCount() > 0, true);
|
||||
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.closeLookupWindow();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(false);
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||
assert.equal(testGlobals.selectionClearCount() > 0, true);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
const closeCommands = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
);
|
||||
assert.deepEqual(closeCommands.slice(-2), [
|
||||
{ type: 'setVisible', visible: false },
|
||||
{ type: 'clearActiveTextSource' },
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
const closeCommands = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
);
|
||||
assert.deepEqual(closeCommands.slice(-2), [
|
||||
{ type: 'setVisible', visible: false },
|
||||
{ type: 'clearActiveTextSource' },
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(0);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(0);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
assert.equal(handlers.moveSelectionForController(1), true);
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
assert.equal(handlers.moveSelectionForController(-1), true);
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(3);
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
handlers.handleSubtitleContentUpdated();
|
||||
setWordCount(4);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
|
||||
}) => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
openControllerSelectModal: () => void;
|
||||
openControllerDebugModal: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
@@ -30,6 +34,7 @@ export function createKeyboardHandlers(
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -105,6 +110,39 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'cycleAudioSource',
|
||||
direction,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupPlayCurrentAudio() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'playCurrentAudio',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'scrollBy',
|
||||
deltaX,
|
||||
deltaY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanFrontendScanSelectedText() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
@@ -115,6 +153,16 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanFrontendClearActiveTextSource() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'clearActiveTextSource',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey || e.metaKey;
|
||||
}
|
||||
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||
}
|
||||
|
||||
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function getSubtitleWordNodes(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||
);
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||
for (const wordNode of wordNodes) {
|
||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
function clearNativeSubtitleSelection(): void {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
clearKeyboardSelectedWordClasses(wordNodes);
|
||||
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
ctx.state.keyboardSelectionVisible = false;
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
ctx.state.keyboardSelectedWordIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
const shouldRefreshLookup =
|
||||
pendingLookupRefreshAfterSubtitleSeek &&
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||
@@ -165,23 +231,32 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
if (resetSelectionToStartOnNextSubtitleSync) {
|
||||
ctx.state.keyboardSelectedWordIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
}
|
||||
|
||||
const selectedIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
);
|
||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (selectedWordNode) {
|
||||
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
const nextIndex = currentIndex + delta;
|
||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
return 'moved';
|
||||
}
|
||||
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (!selectedWordNode) return false;
|
||||
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
selectWordNodeText(selectedWordNode);
|
||||
|
||||
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
|
||||
toggleKeyboardDrivenMode();
|
||||
}
|
||||
|
||||
function handleSubtitleContentUpdated(): void {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
return;
|
||||
}
|
||||
resetSelectionToStartOnNextSubtitleSync = true;
|
||||
}
|
||||
|
||||
function handleLookupWindowToggleRequested(): void {
|
||||
if (ctx.state.yomitanPopupVisible) {
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
closeLookupWindow();
|
||||
return;
|
||||
}
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
function closeLookupWindow(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveSelectionForController(delta: -1 | 1): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const result = moveKeyboardSelection(delta);
|
||||
if (result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result === 'start-boundary' || result === 'end-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function forwardPopupKeydownForController(
|
||||
key: string,
|
||||
code: string,
|
||||
repeat: boolean = true,
|
||||
): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mineSelectedFromController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupMineSelected();
|
||||
return true;
|
||||
}
|
||||
|
||||
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupCycleAudioSource(direction);
|
||||
return true;
|
||||
}
|
||||
|
||||
function playCurrentAudioForController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupPlayCurrentAudio();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreOverlayKeyboardFocus(): void {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
window.focus();
|
||||
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
|
||||
const key = e.code;
|
||||
if (key === 'ArrowLeft') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
return true;
|
||||
}
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
if (result === 'end-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
if (result === 'end-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
clearNativeSubtitleSelection();
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
syncKeyboardTokenSelection();
|
||||
return;
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.controllerSelectModalOpen) {
|
||||
options.handleControllerSelectKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.controllerDebugModalOpen) {
|
||||
options.handleControllerDebugKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.sessionHelpModalOpen) {
|
||||
options.handleSessionHelpKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControllerModalShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
options.openControllerDebugModal();
|
||||
} else {
|
||||
options.openControllerSelectModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
|
||||
setupMpvInputForwarding,
|
||||
updateKeybindings,
|
||||
syncKeyboardTokenSelection,
|
||||
handleSubtitleContentUpdated,
|
||||
handleKeyboardModeToggleRequested,
|
||||
handleLookupWindowToggleRequested,
|
||||
closeLookupWindow,
|
||||
moveSelectionForController,
|
||||
forwardPopupKeydownForController,
|
||||
mineSelectedFromController,
|
||||
cyclePopupAudioSourceForController,
|
||||
playCurrentAudioForController,
|
||||
scrollPopupByController,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
<body>
|
||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||
<div id="overlay" tabindex="-1">
|
||||
<div
|
||||
id="controllerStatusToast"
|
||||
class="controller-status-toast hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div
|
||||
id="overlayErrorToast"
|
||||
class="overlay-error-toast hidden"
|
||||
@@ -192,6 +198,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Controller Selection</div>
|
||||
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="controllerSelectHint" class="runtime-options-hint">
|
||||
Arrow keys: select controller · Enter: save · Esc: close
|
||||
</div>
|
||||
<ul id="controllerSelectList" class="runtime-options-list"></ul>
|
||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||
<div class="subsync-footer">
|
||||
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
|
||||
Save Controller
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content controller-debug-content">
|
||||
<div
|
||||
id="controllerDebugToast"
|
||||
class="controller-debug-toast hidden"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Controller Debug</div>
|
||||
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="controllerDebugStatus" class="runtime-options-status"></div>
|
||||
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
|
||||
<div class="controller-debug-grid">
|
||||
<div>
|
||||
<div class="jimaku-section-title">Axes</div>
|
||||
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="jimaku-section-title">Buttons</div>
|
||||
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
<div class="controller-debug-span">
|
||||
<div class="controller-debug-section-header">
|
||||
<div class="jimaku-section-title">Config</div>
|
||||
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content session-help-content">
|
||||
<div class="modal-header">
|
||||
|
||||
237
src/renderer/modals/controller-debug.test.ts
Normal file
237
src/renderer/modals/controller-debug.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerDebugModal } from './controller-debug.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);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
state.controllerRawAxes = [0.5, -0.25];
|
||||
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
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: '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',
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerDebugModal();
|
||||
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
|
||||
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
|
||||
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
|
||||
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller debug modal copies buttonIndices config to clipboard', async () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
window?: unknown;
|
||||
navigator?: unknown;
|
||||
};
|
||||
const previousWindow = globals.window;
|
||||
const previousNavigator = globals.navigator;
|
||||
const copied: string[] = [];
|
||||
const handlers: { copy: null | (() => void) } = { copy: null };
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText: async (text: string) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
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: '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',
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugCopy: {
|
||||
addEventListener: (_event: string, handler: () => void) => {
|
||||
handlers.copy = handler;
|
||||
},
|
||||
},
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerDebugModal();
|
||||
if (handlers.copy) {
|
||||
handlers.copy();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
|
||||
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
|
||||
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
192
src/renderer/modals/controller-debug.ts
Normal file
192
src/renderer/modals/controller-debug.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function formatAxes(values: number[]): string {
|
||||
if (values.length === 0) return 'No controller axes available.';
|
||||
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
|
||||
}
|
||||
|
||||
function formatButtons(
|
||||
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
|
||||
): string {
|
||||
if (values.length === 0) return 'No controller buttons available.';
|
||||
return values
|
||||
.map(
|
||||
(button, index) =>
|
||||
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatButtonIndices(
|
||||
value:
|
||||
| {
|
||||
select: number;
|
||||
buttonSouth: number;
|
||||
buttonEast: number;
|
||||
buttonNorth: number;
|
||||
buttonWest: number;
|
||||
leftShoulder: number;
|
||||
rightShoulder: number;
|
||||
leftStickPress: number;
|
||||
rightStickPress: number;
|
||||
leftTrigger: number;
|
||||
rightTrigger: number;
|
||||
}
|
||||
| null,
|
||||
): string {
|
||||
if (!value) {
|
||||
return 'No controller config loaded.';
|
||||
}
|
||||
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API unavailable.');
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export function createControllerDebugModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function setStatus(message: string, isError: boolean = false): void {
|
||||
ctx.dom.controllerDebugStatus.textContent = message;
|
||||
if (isError) {
|
||||
ctx.dom.controllerDebugStatus.classList.add('error');
|
||||
} else {
|
||||
ctx.dom.controllerDebugStatus.classList.remove('error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearToastTimer(): void {
|
||||
if (toastTimer === null) return;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = null;
|
||||
}
|
||||
|
||||
function hideToast(): void {
|
||||
clearToastTimer();
|
||||
ctx.dom.controllerDebugToast.classList.add('hidden');
|
||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||
}
|
||||
|
||||
function showToast(message: string, isError: boolean = false): void {
|
||||
clearToastTimer();
|
||||
ctx.dom.controllerDebugToast.textContent = message;
|
||||
ctx.dom.controllerDebugToast.classList.remove('hidden');
|
||||
if (isError) {
|
||||
ctx.dom.controllerDebugToast.classList.add('error');
|
||||
} else {
|
||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||
}
|
||||
toastTimer = setTimeout(() => {
|
||||
hideToast();
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const activeDevice = ctx.state.connectedGamepads.find(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
);
|
||||
setStatus(
|
||||
activeDevice?.id ??
|
||||
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
|
||||
);
|
||||
ctx.dom.controllerDebugSummary.textContent =
|
||||
ctx.state.connectedGamepads.length > 0
|
||||
? ctx.state.connectedGamepads
|
||||
.map((device) => {
|
||||
const tags = [
|
||||
`#${device.index}`,
|
||||
device.mapping,
|
||||
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
||||
].filter(Boolean);
|
||||
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
|
||||
})
|
||||
.join('\n')
|
||||
: 'Connect a controller and press any button to populate raw input values.';
|
||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async function copyButtonIndicesToClipboard(): Promise<void> {
|
||||
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
|
||||
if (text.length === 0 || text === 'No controller config loaded.') {
|
||||
setStatus('No buttonIndices config available to copy.', true);
|
||||
showToast('No buttonIndices config available to copy.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeTextToClipboard(text);
|
||||
setStatus('Copied controller buttonIndices config.');
|
||||
showToast('Copied controller buttonIndices config.');
|
||||
} catch {
|
||||
setStatus('Failed to copy controller buttonIndices config.', true);
|
||||
showToast('Failed to copy controller buttonIndices config.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function openControllerDebugModal(): void {
|
||||
ctx.state.controllerDebugModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerDebugModal.classList.remove('hidden');
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||
hideToast();
|
||||
render();
|
||||
}
|
||||
|
||||
function closeControllerDebugModal(): void {
|
||||
if (!ctx.state.controllerDebugModalOpen) return;
|
||||
ctx.state.controllerDebugModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerDebugModal.classList.add('hidden');
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
|
||||
hideToast();
|
||||
window.electronAPI.notifyOverlayModalClosed('controller-debug');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeControllerDebugModal();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateSnapshot(): void {
|
||||
if (!ctx.state.controllerDebugModalOpen) return;
|
||||
render();
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.controllerDebugClose.addEventListener('click', () => {
|
||||
closeControllerDebugModal();
|
||||
});
|
||||
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
|
||||
void copyButtonIndicesToClipboard();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openControllerDebugModal,
|
||||
closeControllerDebugModal,
|
||||
handleControllerDebugKeydown,
|
||||
updateSnapshot,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
727
src/renderer/modals/controller-select.test.ts
Normal file
727
src/renderer/modals/controller-select.test.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerSelectModal } from './controller-select.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),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller select modal saves the selected preferred controller', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async (update: {
|
||||
preferredGamepadId: string;
|
||||
preferredGamepadLabel: string;
|
||||
}) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const overlayClassList = createClassList();
|
||||
const state = createRendererState();
|
||||
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 },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
|
||||
await modal.handleControllerSelectKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(saved, [
|
||||
{
|
||||
preferredGamepadId: 'pad-2',
|
||||
preferredGamepadLabel: 'pad-2',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal preserves manual selection while controller 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 },
|
||||
{ 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 },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 0);
|
||||
|
||||
modal.handleControllerSelectKeydown({
|
||||
key: 'ArrowDown',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
|
||||
modal.updateDevices();
|
||||
|
||||
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 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 });
|
||||
}
|
||||
});
|
||||
264
src/renderer/modals/controller-select.ts
Normal file
264
src/renderer/modals/controller-select.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function clampSelectedIndex(ctx: RendererContext): void {
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
||||
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
|
||||
ctx.state.connectedGamepads.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function createControllerSelectModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let selectedControllerId: string | null = null;
|
||||
let lastRenderedDevicesKey = '';
|
||||
let lastRenderedActiveGamepadId: string | null = null;
|
||||
let lastRenderedPreferredId = '';
|
||||
|
||||
function getDevicesKey(): string {
|
||||
return ctx.state.connectedGamepads
|
||||
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
|
||||
.join('||');
|
||||
}
|
||||
|
||||
function syncSelectedControllerId(): void {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
selectedControllerId = selected?.id ?? null;
|
||||
}
|
||||
|
||||
function syncSelectedIndexToCurrentController(): void {
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
);
|
||||
if (activeIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = activeIndex;
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
|
||||
if (preferredIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
clampSelectedIndex(ctx);
|
||||
syncSelectedControllerId();
|
||||
}
|
||||
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.dom.controllerSelectStatus.textContent = message;
|
||||
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
ctx.dom.controllerSelectList.innerHTML = '';
|
||||
clampSelectedIndex(ctx);
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
ctx.state.connectedGamepads.forEach((device, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'runtime-options-list-entry';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'runtime-options-item runtime-options-item-button';
|
||||
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 === preferredId ? 'saved' : null,
|
||||
].filter(Boolean);
|
||||
meta.textContent = tags.join(' · ');
|
||||
|
||||
button.appendChild(label);
|
||||
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);
|
||||
});
|
||||
|
||||
lastRenderedDevicesKey = getDevicesKey();
|
||||
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
||||
lastRenderedPreferredId = preferredId;
|
||||
}
|
||||
|
||||
function updateDevices(): void {
|
||||
if (!ctx.state.controllerSelectModalOpen) return;
|
||||
if (selectedControllerId) {
|
||||
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === selectedControllerId,
|
||||
);
|
||||
if (preservedIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
||||
} else {
|
||||
syncSelectedIndexToCurrentController();
|
||||
}
|
||||
} else {
|
||||
syncSelectedIndexToCurrentController();
|
||||
}
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const shouldRender =
|
||||
getDevicesKey() !== lastRenderedDevicesKey ||
|
||||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
||||
preferredId !== lastRenderedPreferredId;
|
||||
if (shouldRender) {
|
||||
renderList();
|
||||
}
|
||||
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus('No controllers detected.');
|
||||
return;
|
||||
}
|
||||
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
|
||||
if (
|
||||
currentStatus !== 'No controller selected.' &&
|
||||
!currentStatus.startsWith('Saved preferred controller:')
|
||||
) {
|
||||
setStatus('Select a controller to save as preferred.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedController(): Promise<void> {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
if (!selected) {
|
||||
setStatus('No controller selected.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.saveControllerPreference({
|
||||
preferredGamepadId: selected.id,
|
||||
preferredGamepadLabel: selected.id,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save preferred controller: ${message}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.controllerConfig) {
|
||||
ctx.state.controllerConfig.preferredGamepadId = selected.id;
|
||||
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
|
||||
}
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): void {
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
syncSelectedIndexToCurrentController();
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
|
||||
window.focus();
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
renderList();
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus('No controllers detected.');
|
||||
} else {
|
||||
setStatus('Select a controller to save as preferred.');
|
||||
}
|
||||
}
|
||||
|
||||
function closeControllerSelectModal(): void {
|
||||
if (!ctx.state.controllerSelectModalOpen) return;
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('controller-select');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeControllerSelectModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
|
||||
event.preventDefault();
|
||||
if (ctx.state.connectedGamepads.length > 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
||||
ctx.state.connectedGamepads.length - 1,
|
||||
ctx.state.controllerDeviceSelectedIndex + 1,
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
|
||||
event.preventDefault();
|
||||
if (ctx.state.connectedGamepads.length > 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = Math.max(
|
||||
0,
|
||||
ctx.state.controllerDeviceSelectedIndex - 1,
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void saveSelectedController();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.controllerSelectClose.addEventListener('click', () => {
|
||||
closeControllerSelectModal();
|
||||
});
|
||||
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
||||
void saveSelectedController();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openControllerSelectModal,
|
||||
closeControllerSelectModal,
|
||||
handleControllerSelectKeydown,
|
||||
updateDevices,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,11 @@ import type {
|
||||
ConfigHotReloadPayload,
|
||||
} from '../types';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||
import { createMouseHandlers } from './handlers/mouse.js';
|
||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||
import { createControllerDebugModal } from './modals/controller-debug.js';
|
||||
import { createControllerSelectModal } from './modals/controller-select.js';
|
||||
import { createJimakuModal } from './modals/jimaku.js';
|
||||
import { createKikuModal } from './modals/kiku.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
@@ -36,6 +40,7 @@ import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { createRendererState } from './state.js';
|
||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||
import {
|
||||
createRendererRecoveryController,
|
||||
registerRendererGlobalErrorHandlers,
|
||||
@@ -55,6 +60,8 @@ const ctx = {
|
||||
|
||||
function isAnySettingsModalOpen(): boolean {
|
||||
return (
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
|
||||
|
||||
function isAnyModalOpen(): boolean {
|
||||
return (
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
},
|
||||
});
|
||||
const mouseHandlers = createMouseHandlers(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
||||
let lastSubtitlePreview = '';
|
||||
let lastSecondarySubtitlePreview = '';
|
||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controllerAnimationFrameId: number | null = null;
|
||||
|
||||
function truncateForErrorLog(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
||||
}
|
||||
|
||||
function getActiveModal(): string | null {
|
||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
|
||||
}
|
||||
|
||||
function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.controllerSelectModalOpen) {
|
||||
controllerSelectModal.closeControllerSelectModal();
|
||||
}
|
||||
if (ctx.state.controllerDebugModalOpen) {
|
||||
controllerDebugModal.closeControllerDebugModal();
|
||||
}
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
jimakuModal.closeJimakuModal();
|
||||
}
|
||||
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
|
||||
function applyControllerSnapshot(snapshot: {
|
||||
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
|
||||
activeGamepadId: string | null;
|
||||
rawAxes: number[];
|
||||
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
|
||||
}): void {
|
||||
controllerStatusIndicator.update({
|
||||
connectedGamepads: snapshot.connectedGamepads,
|
||||
activeGamepadId: snapshot.activeGamepadId,
|
||||
});
|
||||
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
||||
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
||||
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||
controllerSelectModal.updateDevices();
|
||||
controllerDebugModal.updateSnapshot();
|
||||
}
|
||||
|
||||
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||
}
|
||||
|
||||
function emitControllerPopupJump(deltaPixels: number): void {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
|
||||
}
|
||||
|
||||
function startControllerPolling(): void {
|
||||
if (controllerAnimationFrameId !== null) {
|
||||
cancelAnimationFrame(controllerAnimationFrameId);
|
||||
controllerAnimationFrameId = null;
|
||||
}
|
||||
|
||||
const gamepadController = createGamepadController({
|
||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||
getConfig: () =>
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
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: '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',
|
||||
},
|
||||
},
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||
getInteractionBlocked: () => isAnyModalOpen(),
|
||||
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
||||
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
||||
closeLookup: () => {
|
||||
keyboardHandlers.closeLookupWindow();
|
||||
},
|
||||
moveSelection: (delta) => {
|
||||
keyboardHandlers.moveSelectionForController(delta);
|
||||
},
|
||||
mineCard: () => {
|
||||
keyboardHandlers.mineSelectedFromController();
|
||||
},
|
||||
quitMpv: () => {
|
||||
window.electronAPI.sendMpvCommand(['quit']);
|
||||
},
|
||||
previousAudio: () => {
|
||||
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
||||
},
|
||||
nextAudio: () => {
|
||||
keyboardHandlers.cyclePopupAudioSourceForController(1);
|
||||
},
|
||||
playCurrentAudio: () => {
|
||||
keyboardHandlers.playCurrentAudioForController();
|
||||
},
|
||||
toggleMpvPause: () => {
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
},
|
||||
scrollPopup: (deltaPixels) => {
|
||||
emitControllerPopupScroll(deltaPixels);
|
||||
},
|
||||
jumpPopup: (deltaPixels) => {
|
||||
emitControllerPopupJump(deltaPixels);
|
||||
},
|
||||
onState: (snapshot) => {
|
||||
applyControllerSnapshot(snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
const poll = (now: number): void => {
|
||||
gamepadController.poll(now);
|
||||
controllerAnimationFrameId = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
controllerAnimationFrameId = requestAnimationFrame(poll);
|
||||
}
|
||||
|
||||
function restoreOverlayInteractionAfterError(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
|
||||
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||
}
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||
measurementReporter.schedule();
|
||||
|
||||
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
|
||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
|
||||
mouseHandlers.setupDragging();
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
try {
|
||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||
} catch (error) {
|
||||
console.error('Failed to load controller config.', error);
|
||||
ctx.state.controllerConfig = null;
|
||||
}
|
||||
startControllerPolling();
|
||||
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {
|
||||
ControllerButtonSnapshot,
|
||||
ControllerDeviceInfo,
|
||||
ResolvedControllerConfig,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
@@ -53,6 +56,15 @@ export type RendererState = {
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
controllerSelectModalOpen: boolean;
|
||||
controllerDebugModalOpen: boolean;
|
||||
controllerDeviceSelectedIndex: number;
|
||||
controllerConfig: ResolvedControllerConfig | null;
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
controllerRawAxes: number[];
|
||||
controllerRawButtons: ControllerButtonSnapshot[];
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
|
||||
@@ -82,6 +94,7 @@ export type RendererState = {
|
||||
chordPending: boolean;
|
||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||
keyboardDrivenModeEnabled: boolean;
|
||||
keyboardSelectionVisible: boolean;
|
||||
keyboardSelectedWordIndex: number | null;
|
||||
yomitanPopupVisible: boolean;
|
||||
};
|
||||
@@ -122,6 +135,15 @@ export function createRendererState(): RendererState {
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
controllerDeviceSelectedIndex: 0,
|
||||
controllerConfig: null,
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
controllerRawAxes: [],
|
||||
controllerRawButtons: [],
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
|
||||
@@ -151,6 +173,7 @@ export function createRendererState(): RendererState {
|
||||
chordPending: false,
|
||||
chordTimeout: null,
|
||||
keyboardDrivenModeEnabled: false,
|
||||
keyboardSelectionVisible: false,
|
||||
keyboardSelectedWordIndex: null,
|
||||
yomitanPopupVisible: false,
|
||||
};
|
||||
|
||||
@@ -55,6 +55,34 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.controller-status-toast {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(138, 213, 202, 0.45);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
||||
color: rgba(228, 255, 251, 0.98);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.controller-status-toast:not(.hidden) {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.overlay-error-toast {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
@@ -321,6 +349,12 @@ body.settings-modal-open #subtitleContainer {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.settings-modal-open iframe.yomitan-popup,
|
||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .c {
|
||||
display: inline;
|
||||
position: relative;
|
||||
@@ -1013,6 +1047,10 @@ iframe[id^='yomitan-popup'] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.runtime-options-list-entry {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.runtime-options-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1022,7 +1060,15 @@ iframe[id^='yomitan-popup'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-options-item:last-child {
|
||||
.runtime-options-item-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.runtime-options-list-entry:last-child .runtime-options-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -1030,6 +1076,11 @@ iframe[id^='yomitan-popup'] {
|
||||
background: rgba(100, 180, 255, 0.15);
|
||||
}
|
||||
|
||||
.runtime-options-item-button:focus-visible {
|
||||
outline: 2px solid rgba(100, 180, 255, 0.85);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.runtime-options-label {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
@@ -1055,12 +1106,84 @@ iframe[id^='yomitan-popup'] {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.controller-debug-content {
|
||||
position: relative;
|
||||
width: min(760px, 94%);
|
||||
}
|
||||
|
||||
.controller-debug-toast {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 56px;
|
||||
z-index: 2;
|
||||
max-width: min(320px, calc(100% - 88px));
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(120, 214, 168, 0.34);
|
||||
background: rgba(20, 38, 30, 0.96);
|
||||
color: rgba(220, 255, 232, 0.98);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.controller-debug-toast.error {
|
||||
border-color: rgba(255, 143, 143, 0.34);
|
||||
background: rgba(52, 22, 24, 0.96);
|
||||
color: rgba(255, 225, 225, 0.98);
|
||||
}
|
||||
|
||||
.controller-debug-summary {
|
||||
min-height: 18px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.controller-debug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controller-debug-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.controller-debug-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.controller-debug-pre {
|
||||
min-height: 220px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.session-help-content {
|
||||
width: min(760px, 92%);
|
||||
max-height: 84%;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.controller-debug-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.session-help-shortcut,
|
||||
.session-help-warning,
|
||||
.session-help-status {
|
||||
|
||||
@@ -2,6 +2,7 @@ export type RendererDom = {
|
||||
subtitleRoot: HTMLElement;
|
||||
subtitleContainer: HTMLElement;
|
||||
overlay: HTMLElement;
|
||||
controllerStatusToast: HTMLDivElement;
|
||||
overlayErrorToast: HTMLDivElement;
|
||||
secondarySubContainer: HTMLElement;
|
||||
secondarySubRoot: HTMLElement;
|
||||
@@ -56,6 +57,23 @@ export type RendererDom = {
|
||||
subsyncRunButton: HTMLButtonElement;
|
||||
subsyncStatus: HTMLDivElement;
|
||||
|
||||
controllerSelectModal: HTMLDivElement;
|
||||
controllerSelectClose: HTMLButtonElement;
|
||||
controllerSelectHint: HTMLDivElement;
|
||||
controllerSelectStatus: HTMLDivElement;
|
||||
controllerSelectList: HTMLUListElement;
|
||||
controllerSelectSave: HTMLButtonElement;
|
||||
|
||||
controllerDebugModal: HTMLDivElement;
|
||||
controllerDebugClose: HTMLButtonElement;
|
||||
controllerDebugCopy: HTMLButtonElement;
|
||||
controllerDebugToast: HTMLDivElement;
|
||||
controllerDebugStatus: HTMLDivElement;
|
||||
controllerDebugSummary: HTMLDivElement;
|
||||
controllerDebugAxes: HTMLPreElement;
|
||||
controllerDebugButtons: HTMLPreElement;
|
||||
controllerDebugButtonIndices: HTMLPreElement;
|
||||
|
||||
sessionHelpModal: HTMLDivElement;
|
||||
sessionHelpClose: HTMLButtonElement;
|
||||
sessionHelpShortcut: HTMLDivElement;
|
||||
@@ -78,6 +96,7 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||
@@ -132,6 +151,23 @@ export function resolveRendererDom(): RendererDom {
|
||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
||||
|
||||
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
||||
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
||||
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'),
|
||||
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
||||
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'),
|
||||
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
|
||||
|
||||
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
|
||||
controllerDebugClose: getRequiredElement<HTMLButtonElement>('controllerDebugClose'),
|
||||
controllerDebugCopy: getRequiredElement<HTMLButtonElement>('controllerDebugCopy'),
|
||||
controllerDebugToast: getRequiredElement<HTMLDivElement>('controllerDebugToast'),
|
||||
controllerDebugStatus: getRequiredElement<HTMLDivElement>('controllerDebugStatus'),
|
||||
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
|
||||
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
|
||||
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
|
||||
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||
|
||||
24
src/renderer/yomitan-display-scroll.test.ts
Normal file
24
src/renderer/yomitan-display-scroll.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
// @ts-expect-error Vendor Yomitan modules are JS-only in this repo.
|
||||
import { Display } from '../../vendor/subminer-yomitan/ext/js/display/display.js';
|
||||
|
||||
test('yomitan display scroll bridge uses popup scroll container instead of window scroll', () => {
|
||||
let scrolledTo: { x: number; y: number } | null = null;
|
||||
const result = Display.prototype._onMessageScrollBy.call(
|
||||
{
|
||||
_windowScroll: {
|
||||
x: 24,
|
||||
y: 80,
|
||||
to(x: number, y: number) {
|
||||
scrolledTo = { x, y };
|
||||
},
|
||||
},
|
||||
},
|
||||
{ deltaX: 12, deltaY: -20 },
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(scrolledTo, { x: 36, y: 60 });
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
||||
|
||||
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const;
|
||||
export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'jimaku',
|
||||
'kiku',
|
||||
'controller-select',
|
||||
'controller-debug',
|
||||
] as const;
|
||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
@@ -12,6 +19,7 @@ export const IPC_CHANNELS = {
|
||||
toggleDevTools: 'toggle-dev-tools',
|
||||
toggleOverlay: 'toggle-overlay',
|
||||
saveSubtitlePosition: 'save-subtitle-position',
|
||||
saveControllerPreference: 'save-controller-preference',
|
||||
setMecabEnabled: 'set-mecab-enabled',
|
||||
mpvCommand: 'mpv-command',
|
||||
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
||||
@@ -32,6 +40,7 @@ export const IPC_CHANNELS = {
|
||||
getMecabStatus: 'get-mecab-status',
|
||||
getKeybindings: 'get-keybindings',
|
||||
getConfigShortcuts: 'get-config-shortcuts',
|
||||
getControllerConfig: 'get-controller-config',
|
||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||
focusMainWindow: 'focus-main-window',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ControllerPreferenceUpdate,
|
||||
JimakuDownloadQuery,
|
||||
JimakuFilesQuery,
|
||||
JimakuSearchQuery,
|
||||
@@ -48,6 +49,16 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null {
|
||||
if (!isObject(value)) return null;
|
||||
if (typeof value.preferredGamepadId !== 'string') return null;
|
||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||
return {
|
||||
preferredGamepadId: value.preferredGamepadId,
|
||||
preferredGamepadLabel: value.preferredGamepadLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||
if (!isObject(value)) return null;
|
||||
const { engine, sourceTrackId } = value;
|
||||
|
||||
128
src/types.ts
128
src/types.ts
@@ -375,6 +375,94 @@ export interface ShortcutsConfig {
|
||||
openJimaku?: string | null;
|
||||
}
|
||||
|
||||
export type ControllerButtonBinding =
|
||||
| 'none'
|
||||
| 'select'
|
||||
| 'buttonSouth'
|
||||
| 'buttonEast'
|
||||
| 'buttonNorth'
|
||||
| 'buttonWest'
|
||||
| 'leftShoulder'
|
||||
| 'rightShoulder'
|
||||
| 'leftStickPress'
|
||||
| 'rightStickPress'
|
||||
| 'leftTrigger'
|
||||
| 'rightTrigger';
|
||||
|
||||
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
|
||||
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
|
||||
|
||||
export interface ControllerBindingsConfig {
|
||||
toggleLookup?: ControllerButtonBinding;
|
||||
closeLookup?: ControllerButtonBinding;
|
||||
toggleKeyboardOnlyMode?: ControllerButtonBinding;
|
||||
mineCard?: ControllerButtonBinding;
|
||||
quitMpv?: ControllerButtonBinding;
|
||||
previousAudio?: ControllerButtonBinding;
|
||||
nextAudio?: ControllerButtonBinding;
|
||||
playCurrentAudio?: ControllerButtonBinding;
|
||||
toggleMpvPause?: ControllerButtonBinding;
|
||||
leftStickHorizontal?: ControllerAxisBinding;
|
||||
leftStickVertical?: ControllerAxisBinding;
|
||||
rightStickHorizontal?: ControllerAxisBinding;
|
||||
rightStickVertical?: ControllerAxisBinding;
|
||||
}
|
||||
|
||||
export interface ControllerButtonIndicesConfig {
|
||||
select?: number;
|
||||
buttonSouth?: number;
|
||||
buttonEast?: number;
|
||||
buttonNorth?: number;
|
||||
buttonWest?: number;
|
||||
leftShoulder?: number;
|
||||
rightShoulder?: number;
|
||||
leftStickPress?: number;
|
||||
rightStickPress?: number;
|
||||
leftTrigger?: number;
|
||||
rightTrigger?: number;
|
||||
}
|
||||
|
||||
export interface ControllerConfig {
|
||||
enabled?: boolean;
|
||||
preferredGamepadId?: string;
|
||||
preferredGamepadLabel?: string;
|
||||
smoothScroll?: boolean;
|
||||
scrollPixelsPerSecond?: number;
|
||||
horizontalJumpPixels?: number;
|
||||
stickDeadzone?: number;
|
||||
triggerInputMode?: ControllerTriggerInputMode;
|
||||
triggerDeadzone?: number;
|
||||
repeatDelayMs?: number;
|
||||
repeatIntervalMs?: number;
|
||||
buttonIndices?: ControllerButtonIndicesConfig;
|
||||
bindings?: ControllerBindingsConfig;
|
||||
}
|
||||
|
||||
export interface ControllerPreferenceUpdate {
|
||||
preferredGamepadId: string;
|
||||
preferredGamepadLabel: string;
|
||||
}
|
||||
|
||||
export interface ControllerDeviceInfo {
|
||||
id: string;
|
||||
index: number;
|
||||
mapping: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerButtonSnapshot {
|
||||
value: number;
|
||||
pressed: boolean;
|
||||
touched?: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerRuntimeSnapshot {
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
rawAxes: number[];
|
||||
rawButtons: ControllerButtonSnapshot[];
|
||||
}
|
||||
|
||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||
|
||||
export interface JimakuConfig {
|
||||
@@ -487,6 +575,7 @@ export interface Config {
|
||||
websocket?: WebSocketConfig;
|
||||
annotationWebsocket?: AnnotationWebSocketConfig;
|
||||
texthooker?: TexthookerConfig;
|
||||
controller?: ControllerConfig;
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
shortcuts?: ShortcutsConfig;
|
||||
secondarySub?: SecondarySubConfig;
|
||||
@@ -514,6 +603,21 @@ export interface ResolvedConfig {
|
||||
websocket: Required<WebSocketConfig>;
|
||||
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
||||
texthooker: Required<TexthookerConfig>;
|
||||
controller: {
|
||||
enabled: boolean;
|
||||
preferredGamepadId: string;
|
||||
preferredGamepadLabel: string;
|
||||
smoothScroll: boolean;
|
||||
scrollPixelsPerSecond: number;
|
||||
horizontalJumpPixels: number;
|
||||
stickDeadzone: number;
|
||||
triggerInputMode: ControllerTriggerInputMode;
|
||||
triggerDeadzone: number;
|
||||
repeatDelayMs: number;
|
||||
repeatIntervalMs: number;
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ControllerBindingsConfig>;
|
||||
};
|
||||
ankiConnect: AnkiConnectConfig & {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
@@ -838,6 +942,8 @@ export interface ConfigHotReloadPayload {
|
||||
secondarySubMode: SecondarySubMode;
|
||||
}
|
||||
|
||||
export type ResolvedControllerConfig = ResolvedConfig['controller'];
|
||||
|
||||
export interface SubtitleHoverTokenPayload {
|
||||
tokenIndex: number | null;
|
||||
}
|
||||
@@ -862,6 +968,8 @@ export interface ElectronAPI {
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
getKeybindings: () => Promise<Keybinding[]>;
|
||||
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
||||
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||
@@ -895,8 +1003,24 @@ export interface ElectronAPI {
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||
notifyOverlayModalClosed: (
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'jimaku'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
| 'controller-debug',
|
||||
) => void;
|
||||
notifyOverlayModalOpened: (
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'jimaku'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
| 'controller-debug',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
}
|
||||
|
||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 66cb7a06f1...979a162904
Reference in New Issue
Block a user