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
|
# 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)
|
## v0.5.6 (2026-03-10)
|
||||||
|
|
||||||
### Fixed
|
### 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
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // 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
|
// Startup Warmups
|
||||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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)
|
## 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.
|
- 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`.
|
- 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
|
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard 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
|
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
||||||
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
||||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
- [**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.
|
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
|
### 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:
|
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.
|
3. Yomitan detects the selection and opens its lookup popup.
|
||||||
4. From the popup, add the word to Anki.
|
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
|
## Creating Anki Cards
|
||||||
|
|
||||||
There are three ways to create cards, depending on your workflow.
|
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
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // 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
|
// Startup Warmups
|
||||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
// 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+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `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
|
## 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.
|
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`
|
- `--whisper-threads`
|
||||||
- `--yt-subgen-audio-format`
|
- `--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
|
## Keybindings
|
||||||
|
|
||||||
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
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",
|
"name": "subminer",
|
||||||
"version": "0.5.6",
|
"version": "0.6.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
@@ -1106,6 +1106,135 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
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', () => {
|
test('runtime options registry is centralized', () => {
|
||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
@@ -1638,6 +1767,7 @@ test('template generator includes known keys', () => {
|
|||||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||||
assert.match(output, /"ai":/);
|
assert.match(output, /"ai":/);
|
||||||
assert.match(output, /"ankiConnect":/);
|
assert.match(output, /"ankiConnect":/);
|
||||||
|
assert.match(output, /"controller":/);
|
||||||
assert.match(output, /"logging":/);
|
assert.match(output, /"logging":/);
|
||||||
assert.match(output, /"websocket":/);
|
assert.match(output, /"websocket":/);
|
||||||
assert.match(output, /"discordPresence":/);
|
assert.match(output, /"discordPresence":/);
|
||||||
@@ -1662,6 +1792,14 @@ test('template generator includes known keys', () => {
|
|||||||
output,
|
output,
|
||||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
/"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, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const {
|
|||||||
annotationWebsocket,
|
annotationWebsocket,
|
||||||
logging,
|
logging,
|
||||||
texthooker,
|
texthooker,
|
||||||
|
controller,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
subsync,
|
subsync,
|
||||||
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
annotationWebsocket,
|
annotationWebsocket,
|
||||||
logging,
|
logging,
|
||||||
texthooker,
|
texthooker,
|
||||||
|
controller,
|
||||||
ankiConnect,
|
ankiConnect,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'annotationWebsocket'
|
| 'annotationWebsocket'
|
||||||
| 'logging'
|
| 'logging'
|
||||||
| 'texthooker'
|
| 'texthooker'
|
||||||
|
| 'controller'
|
||||||
| 'shortcuts'
|
| 'shortcuts'
|
||||||
| 'secondarySub'
|
| 'secondarySub'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
@@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
launchAtStartup: true,
|
launchAtStartup: true,
|
||||||
openBrowser: 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: {
|
shortcuts: {
|
||||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
copySubtitle: 'CommandOrControl+C',
|
copySubtitle: 'CommandOrControl+C',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
for (const requiredPath of [
|
for (const requiredPath of [
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'annotationWebsocket.enabled',
|
'annotationWebsocket.enabled',
|
||||||
|
'controller.enabled',
|
||||||
|
'controller.scrollPixelsPerSecond',
|
||||||
'startupWarmups.lowPowerMode',
|
'startupWarmups.lowPowerMode',
|
||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
@@ -38,6 +40,7 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
const requiredKeys: (typeof keys)[number][] = [
|
const requiredKeys: (typeof keys)[number][] = [
|
||||||
'websocket',
|
'websocket',
|
||||||
'annotationWebsocket',
|
'annotationWebsocket',
|
||||||
|
'controller',
|
||||||
'startupWarmups',
|
'startupWarmups',
|
||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared';
|
|||||||
export function buildCoreConfigOptionRegistry(
|
export function buildCoreConfigOptionRegistry(
|
||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
): ConfigOptionRegistryEntry[] {
|
): ConfigOptionRegistryEntry[] {
|
||||||
|
const controllerButtonEnumValues = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: 'logging.level',
|
path: 'logging.level',
|
||||||
@@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.logging.level,
|
defaultValue: defaultConfig.logging.level,
|
||||||
description: 'Minimum log level for runtime logging.',
|
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',
|
path: 'texthooker.launchAtStartup',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||||
key: 'logging',
|
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',
|
title: 'Startup Warmups',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
|||||||
|
|
||||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
const controllerButtonBindings = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||||
|
|
||||||
if (isObject(src.texthooker)) {
|
if (isObject(src.texthooker)) {
|
||||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||||
@@ -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)) {
|
if (Array.isArray(src.keybindings)) {
|
||||||
resolved.keybindings = src.keybindings.filter(
|
resolved.keybindings = src.keybindings.filter(
|
||||||
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
||||||
|
|||||||
@@ -53,6 +53,48 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
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',
|
getSecondarySubMode: () => 'hover',
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -117,6 +159,48 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
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',
|
getSecondarySubMode: () => 'hover',
|
||||||
getCurrentSecondarySub: () => '',
|
getCurrentSecondarySub: () => '',
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||||
assert.ok(getPlaybackPausedHandler);
|
assert.ok(getPlaybackPausedHandler);
|
||||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
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', () => {
|
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const saves: unknown[] = [];
|
const saves: unknown[] = [];
|
||||||
|
const controllerSaves: unknown[] = [];
|
||||||
const closedModals: unknown[] = [];
|
const closedModals: unknown[] = [];
|
||||||
const openedModals: unknown[] = [];
|
const openedModals: unknown[] = [];
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
@@ -207,6 +299,50 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
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',
|
getSecondarySubMode: () => 'hover',
|
||||||
getCurrentSecondarySub: () => '',
|
getCurrentSecondarySub: () => '',
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||||
assert.deepEqual(openedModals, ['subsync', '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 electron from 'electron';
|
||||||
import type { IpcMainEvent } from 'electron';
|
import type { IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
|
ControllerPreferenceUpdate,
|
||||||
|
ResolvedControllerConfig,
|
||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
@@ -10,6 +12,7 @@ import type {
|
|||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
parseMpvCommand,
|
parseMpvCommand,
|
||||||
|
parseControllerPreferenceUpdate,
|
||||||
parseOptionalForwardingOptions,
|
parseOptionalForwardingOptions,
|
||||||
parseOverlayHostedModal,
|
parseOverlayHostedModal,
|
||||||
parseRuntimeOptionDirection,
|
parseRuntimeOptionDirection,
|
||||||
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getCurrentSecondarySub: () => string;
|
getCurrentSecondarySub: () => string;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
handleMpvCommand: options.handleMpvCommand,
|
handleMpvCommand: options.handleMpvCommand,
|
||||||
getKeybindings: options.getKeybindings,
|
getKeybindings: options.getKeybindings,
|
||||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||||
|
getControllerConfig: options.getControllerConfig,
|
||||||
|
saveControllerPreference: options.saveControllerPreference,
|
||||||
getSecondarySubMode: options.getSecondarySubMode,
|
getSecondarySubMode: options.getSecondarySubMode,
|
||||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||||
focusMainWindow: () => {
|
focusMainWindow: () => {
|
||||||
@@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.saveSubtitlePosition(parsedPosition);
|
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, () => {
|
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||||
return deps.getMecabStatus();
|
return deps.getMecabStatus();
|
||||||
});
|
});
|
||||||
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.getConfiguredShortcuts();
|
return deps.getConfiguredShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||||
|
return deps.getControllerConfig();
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||||
return deps.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 { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
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 { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -3407,6 +3408,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
|
getControllerConfig: () => getResolvedConfig().controller,
|
||||||
|
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||||
|
configService.patchRawConfig({
|
||||||
|
controller: {
|
||||||
|
preferredGamepadId,
|
||||||
|
preferredGamepadLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
getSecondarySubMode: () => appState.secondarySubMode,
|
getSecondarySubMode: () => appState.secondarySubMode,
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||||
|
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||||
|
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||||
@@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
handleMpvCommand: params.handleMpvCommand,
|
handleMpvCommand: params.handleMpvCommand,
|
||||||
getKeybindings: params.getKeybindings,
|
getKeybindings: params.getKeybindings,
|
||||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||||
|
getControllerConfig: params.getControllerConfig,
|
||||||
|
saveControllerPreference: params.saveControllerPreference,
|
||||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||||
getSecondarySubMode: params.getSecondarySubMode,
|
getSecondarySubMode: params.getSecondarySubMode,
|
||||||
getMpvClient: params.getMpvClient,
|
getMpvClient: params.getMpvClient,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
|
|
||||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
|
||||||
|
|
||||||
export interface OverlayWindowResolver {
|
export interface OverlayWindowResolver {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
@@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService(
|
|||||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { OverlayHostedModal };
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
getMecabTokenizer: () => null,
|
getMecabTokenizer: () => null,
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}) as never,
|
getConfiguredShortcuts: () => ({}) as never,
|
||||||
|
getControllerConfig: () => ({}) as never,
|
||||||
|
saveControllerPreference: () => {},
|
||||||
getSecondarySubMode: () => 'hover' as never,
|
getSecondarySubMode: () => 'hover' as never,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
getAnkiConnectStatus: () => false,
|
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';
|
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
||||||
|
|
||||||
export function createSetOverlayVisibleHandler(deps: {
|
export function createSetOverlayVisibleHandler(deps: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RuntimeOptionState } from '../../types';
|
import type { RuntimeOptionState } from '../../types';
|
||||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
type RuntimeOptionsManagerLike = {
|
type RuntimeOptionsManagerLike = {
|
||||||
listOptions: () => RuntimeOptionState[];
|
listOptions: () => RuntimeOptionState[];
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import type {
|
|||||||
OverlayContentMeasurement,
|
OverlayContentMeasurement,
|
||||||
ShortcutsConfig,
|
ShortcutsConfig,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
|
ControllerPreferenceUpdate,
|
||||||
|
ResolvedControllerConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
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> =>
|
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||||
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
notifyOverlayModalClosed: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
notifyOverlayModalOpened: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||||
},
|
},
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
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 { createKeyboardHandlers } from './keyboard.js';
|
||||||
import { createRendererState } from '../state.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 CommandEventDetail = {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -11,6 +14,9 @@ type CommandEventDetail = {
|
|||||||
key?: string;
|
key?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
repeat?: boolean;
|
repeat?: boolean;
|
||||||
|
direction?: number;
|
||||||
|
deltaX?: number;
|
||||||
|
deltaY?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
|
|||||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||||
|
|
||||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
const commandEvents: CommandEventDetail[] = [];
|
const commandEvents: CommandEventDetail[] = [];
|
||||||
const mpvCommands: Array<Array<string | number>> = [];
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
let playbackPausedResponse: boolean | null = false;
|
let playbackPausedResponse: boolean | null = false;
|
||||||
|
let selectionClearCount = 0;
|
||||||
|
let selectionAddCount = 0;
|
||||||
|
|
||||||
let popupVisible = false;
|
let popupVisible = false;
|
||||||
|
|
||||||
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selection = {
|
const selection = {
|
||||||
removeAllRanges: () => {},
|
removeAllRanges: () => {
|
||||||
addRange: () => {},
|
selectionClearCount += 1;
|
||||||
|
},
|
||||||
|
addRange: () => {
|
||||||
|
selectionAddCount += 1;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||||
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
|
|||||||
Object.defineProperty(globalThis, 'window', {
|
Object.defineProperty(globalThis, 'window', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
addEventListener: () => {},
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const listeners = windowListeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
windowListeners.set(type, listeners);
|
||||||
|
},
|
||||||
dispatchEvent: (event: Event) => {
|
dispatchEvent: (event: Event) => {
|
||||||
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||||
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||||
commandEvents.push(detail ?? {});
|
commandEvents.push(detail ?? {});
|
||||||
}
|
}
|
||||||
|
const listeners = windowListeners.get(event.type) ?? [];
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
getComputedStyle: () => ({
|
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() {
|
function restore() {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
|
|||||||
windowFocusCalls: () => windowFocusCalls,
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
dispatchKeydown,
|
dispatchKeydown,
|
||||||
dispatchFocusInOnPopup,
|
dispatchFocusInOnPopup,
|
||||||
|
dispatchWindowEvent,
|
||||||
setPopupVisible: (value: boolean) => {
|
setPopupVisible: (value: boolean) => {
|
||||||
popupVisible = value;
|
popupVisible = value;
|
||||||
},
|
},
|
||||||
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
|
|||||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||||
playbackPausedResponse = value;
|
playbackPausedResponse = value;
|
||||||
},
|
},
|
||||||
|
selectionClearCount: () => selectionClearCount,
|
||||||
|
selectionAddCount: () => selectionAddCount,
|
||||||
restore,
|
restore,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
|
|||||||
function createKeyboardHandlerHarness() {
|
function createKeyboardHandlerHarness() {
|
||||||
const testGlobals = installKeyboardTestGlobals();
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
|
let controllerSelectOpenCount = 0;
|
||||||
|
let controllerDebugOpenCount = 0;
|
||||||
|
let controllerSelectKeydownCount = 0;
|
||||||
|
|
||||||
const createWordNode = (left: number) => ({
|
const createWordNode = (left: number) => ({
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleSubsyncKeydown: () => false,
|
handleSubsyncKeydown: () => false,
|
||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => false,
|
||||||
|
handleControllerSelectKeydown: () => {
|
||||||
|
controllerSelectKeydownCount += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
handleControllerDebugKeydown: () => false,
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
appendClipboardVideoToQueue: () => {},
|
appendClipboardVideoToQueue: () => {},
|
||||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||||
|
openControllerSelectModal: () => {
|
||||||
|
controllerSelectOpenCount += 1;
|
||||||
|
},
|
||||||
|
openControllerDebugModal: () => {
|
||||||
|
controllerDebugOpenCount += 1;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
ctx,
|
||||||
handlers,
|
handlers,
|
||||||
testGlobals,
|
testGlobals,
|
||||||
|
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||||
|
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||||
|
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||||
setWordCount: (count: number) => {
|
setWordCount: (count: number) => {
|
||||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
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 () => {
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
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 () => {
|
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
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 () => {
|
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
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 () => {
|
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
|
|||||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
openSessionHelpModal: (opening: {
|
openSessionHelpModal: (opening: {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
|
|||||||
}) => void;
|
}) => void;
|
||||||
appendClipboardVideoToQueue: () => void;
|
appendClipboardVideoToQueue: () => void;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
|
openControllerSelectModal: () => void;
|
||||||
|
openControllerDebugModal: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// 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';
|
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
|
||||||
const CHORD_MAP = new Map<
|
const CHORD_MAP = new Map<
|
||||||
string,
|
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() {
|
function dispatchYomitanFrontendScanSelectedText() {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
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 {
|
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||||
return e.ctrlKey || e.metaKey;
|
return e.ctrlKey || e.metaKey;
|
||||||
}
|
}
|
||||||
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
|
|||||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
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[] {
|
function getSubtitleWordNodes(): HTMLElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncKeyboardTokenSelection(): void {
|
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||||
const wordNodes = getSubtitleWordNodes();
|
|
||||||
for (const wordNode of wordNodes) {
|
for (const wordNode of wordNodes) {
|
||||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
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) {
|
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||||
ctx.state.keyboardSelectedWordIndex = null;
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
ctx.state.keyboardSelectionVisible = false;
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
|
|||||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
ctx.state.keyboardSelectedWordIndex =
|
ctx.state.keyboardSelectedWordIndex =
|
||||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
const shouldRefreshLookup =
|
const shouldRefreshLookup =
|
||||||
pendingLookupRefreshAfterSubtitleSeek &&
|
pendingLookupRefreshAfterSubtitleSeek &&
|
||||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
(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(
|
const selectedIndex = Math.min(
|
||||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
wordNodes.length - 1,
|
wordNodes.length - 1,
|
||||||
);
|
);
|
||||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
const selectedWordNode = wordNodes[selectedIndex];
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
if (selectedWordNode) {
|
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||||
|
ctx.state.keyboardSelectionVisible = enabled;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
ctx.state.keyboardSelectedWordIndex = null;
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
}
|
}
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
}
|
}
|
||||||
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
const nextIndex = currentIndex + delta;
|
const nextIndex = currentIndex + delta;
|
||||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
|
|||||||
const selectedWordNode = wordNodes[selectedIndex];
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
if (!selectedWordNode) return false;
|
if (!selectedWordNode) return false;
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
selectWordNodeText(selectedWordNode);
|
selectWordNodeText(selectedWordNode);
|
||||||
|
|
||||||
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
|
|||||||
toggleKeyboardDrivenMode();
|
toggleKeyboardDrivenMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSubtitleContentUpdated(): void {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleLookupWindowToggleRequested(): void {
|
function handleLookupWindowToggleRequested(): void {
|
||||||
if (ctx.state.yomitanPopupVisible) {
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
|
closeLookupWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLookupWindow(): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
dispatchYomitanPopupVisibility(false);
|
dispatchYomitanPopupVisibility(false);
|
||||||
|
dispatchYomitanFrontendClearActiveTextSource();
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
restoreOverlayKeyboardFocus();
|
restoreOverlayKeyboardFocus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
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();
|
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 {
|
function restoreOverlayKeyboardFocus(): void {
|
||||||
void window.electronAPI.focusMainWindow();
|
void window.electronAPI.focusMainWindow();
|
||||||
window.focus();
|
window.focus();
|
||||||
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
|
|||||||
const key = e.code;
|
const key = e.code;
|
||||||
if (key === 'ArrowLeft') {
|
if (key === 'ArrowLeft') {
|
||||||
const result = moveKeyboardSelection(-1);
|
const result = moveKeyboardSelection(-1);
|
||||||
if (result === 'start-boundary') {
|
if (result === 'start-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||||
}
|
}
|
||||||
return result !== 'no-words';
|
return true;
|
||||||
}
|
}
|
||||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
const result = moveKeyboardSelection(1);
|
const result = moveKeyboardSelection(1);
|
||||||
if (result === 'end-boundary') {
|
if (result === 'end-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||||
}
|
}
|
||||||
return result !== 'no-words';
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
|
|||||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||||
const result = moveKeyboardSelection(-1);
|
const result = moveKeyboardSelection(-1);
|
||||||
if (result === 'start-boundary') {
|
if (result === 'start-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||||
} else if (popupVisible && result === 'moved') {
|
} else if (popupVisible && result === 'moved') {
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
const result = moveKeyboardSelection(1);
|
const result = moveKeyboardSelection(1);
|
||||||
if (result === 'end-boundary') {
|
if (result === 'end-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||||
} else if (popupVisible && result === 'moved') {
|
} else if (popupVisible && result === 'moved') {
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
restoreOverlayKeyboardFocus();
|
restoreOverlayKeyboardFocus();
|
||||||
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
|
||||||
if (handleYomitanPopupKeybind(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
return;
|
return;
|
||||||
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
|
|||||||
options.handleJimakuKeydown(e);
|
options.handleJimakuKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
|
options.handleControllerSelectKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.state.controllerDebugModalOpen) {
|
||||||
|
options.handleControllerDebugKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ctx.state.sessionHelpModalOpen) {
|
if (ctx.state.sessionHelpModalOpen) {
|
||||||
options.handleSessionHelpKeydown(e);
|
options.handleSessionHelpKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||||
|
!isControllerModalShortcut(e)
|
||||||
|
) {
|
||||||
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isControllerModalShortcut(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
options.openControllerDebugModal();
|
||||||
|
} else {
|
||||||
|
options.openControllerSelectModal();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const keyString = keyEventToString(e);
|
const keyString = keyEventToString(e);
|
||||||
const command = ctx.state.keybindingsMap.get(keyString);
|
const command = ctx.state.keybindingsMap.get(keyString);
|
||||||
|
|
||||||
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
|
|||||||
setupMpvInputForwarding,
|
setupMpvInputForwarding,
|
||||||
updateKeybindings,
|
updateKeybindings,
|
||||||
syncKeyboardTokenSelection,
|
syncKeyboardTokenSelection,
|
||||||
|
handleSubtitleContentUpdated,
|
||||||
handleKeyboardModeToggleRequested,
|
handleKeyboardModeToggleRequested,
|
||||||
handleLookupWindowToggleRequested,
|
handleLookupWindowToggleRequested,
|
||||||
|
closeLookupWindow,
|
||||||
|
moveSelectionForController,
|
||||||
|
forwardPopupKeydownForController,
|
||||||
|
mineSelectedFromController,
|
||||||
|
cyclePopupAudioSourceForController,
|
||||||
|
playCurrentAudioForController,
|
||||||
|
scrollPopupByController,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||||
<div id="overlay" tabindex="-1">
|
<div id="overlay" tabindex="-1">
|
||||||
|
<div
|
||||||
|
id="controllerStatusToast"
|
||||||
|
class="controller-status-toast hidden"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
id="overlayErrorToast"
|
id="overlayErrorToast"
|
||||||
class="overlay-error-toast hidden"
|
class="overlay-error-toast hidden"
|
||||||
@@ -192,6 +198,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content session-help-content">
|
<div class="modal-content session-help-content">
|
||||||
<div class="modal-header">
|
<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,
|
ConfigHotReloadPayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||||
|
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||||
import { createMouseHandlers } from './handlers/mouse.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 { createJimakuModal } from './modals/jimaku.js';
|
||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.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 { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { createRendererState } from './state.js';
|
import { createRendererState } from './state.js';
|
||||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||||
|
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||||
import {
|
import {
|
||||||
createRendererRecoveryController,
|
createRendererRecoveryController,
|
||||||
registerRendererGlobalErrorHandlers,
|
registerRendererGlobalErrorHandlers,
|
||||||
@@ -55,6 +60,8 @@ const ctx = {
|
|||||||
|
|
||||||
function isAnySettingsModalOpen(): boolean {
|
function isAnySettingsModalOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
|
ctx.state.controllerSelectModalOpen ||
|
||||||
|
ctx.state.controllerDebugModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
|
|||||||
|
|
||||||
function isAnyModalOpen(): boolean {
|
function isAnyModalOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
|
ctx.state.controllerSelectModalOpen ||
|
||||||
|
ctx.state.controllerDebugModalOpen ||
|
||||||
ctx.state.jimakuModalOpen ||
|
ctx.state.jimakuModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
|
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
|
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||||
appendClipboardVideoToQueue: () => {
|
appendClipboardVideoToQueue: () => {
|
||||||
void window.electronAPI.appendClipboardVideoToQueue();
|
void window.electronAPI.appendClipboardVideoToQueue();
|
||||||
},
|
},
|
||||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
|
openControllerSelectModal: () => {
|
||||||
|
controllerSelectModal.openControllerSelectModal();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
|
},
|
||||||
|
openControllerDebugModal: () => {
|
||||||
|
controllerDebugModal.openControllerDebugModal();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const mouseHandlers = createMouseHandlers(ctx, {
|
const mouseHandlers = createMouseHandlers(ctx, {
|
||||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||||
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
|||||||
let lastSubtitlePreview = '';
|
let lastSubtitlePreview = '';
|
||||||
let lastSecondarySubtitlePreview = '';
|
let lastSecondarySubtitlePreview = '';
|
||||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let controllerAnimationFrameId: number | null = null;
|
||||||
|
|
||||||
function truncateForErrorLog(text: string): string {
|
function truncateForErrorLog(text: string): string {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveModal(): string | null {
|
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.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dismissActiveUiAfterError(): void {
|
function dismissActiveUiAfterError(): void {
|
||||||
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
|
controllerSelectModal.closeControllerSelectModal();
|
||||||
|
}
|
||||||
|
if (ctx.state.controllerDebugModalOpen) {
|
||||||
|
controllerDebugModal.closeControllerDebugModal();
|
||||||
|
}
|
||||||
if (ctx.state.jimakuModalOpen) {
|
if (ctx.state.jimakuModalOpen) {
|
||||||
jimakuModal.closeJimakuModal();
|
jimakuModal.closeJimakuModal();
|
||||||
}
|
}
|
||||||
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
|
|||||||
syncSettingsModalSubtitleSuppression();
|
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 {
|
function restoreOverlayInteractionAfterError(): void {
|
||||||
ctx.state.isOverSubtitle = false;
|
ctx.state.isOverSubtitle = false;
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
|
|||||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
|
keyboardHandlers.handleSubtitleContentUpdated();
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
|
|||||||
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
}
|
}
|
||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||||
|
keyboardHandlers.handleSubtitleContentUpdated();
|
||||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
|
|||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
|
controllerSelectModal.wireDomEvents();
|
||||||
|
controllerDebugModal.wireDomEvents();
|
||||||
sessionHelpModal.wireDomEvents();
|
sessionHelpModal.wireDomEvents();
|
||||||
|
|
||||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||||
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
|
|||||||
mouseHandlers.setupDragging();
|
mouseHandlers.setupDragging();
|
||||||
|
|
||||||
await keyboardHandlers.setupMpvInputForwarding();
|
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();
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ControllerButtonSnapshot,
|
||||||
|
ControllerDeviceInfo,
|
||||||
|
ResolvedControllerConfig,
|
||||||
JimakuEntry,
|
JimakuEntry,
|
||||||
JimakuFileEntry,
|
JimakuFileEntry,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
@@ -53,6 +56,15 @@ export type RendererState = {
|
|||||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||||
subsyncSubmitting: boolean;
|
subsyncSubmitting: boolean;
|
||||||
|
|
||||||
|
controllerSelectModalOpen: boolean;
|
||||||
|
controllerDebugModalOpen: boolean;
|
||||||
|
controllerDeviceSelectedIndex: number;
|
||||||
|
controllerConfig: ResolvedControllerConfig | null;
|
||||||
|
connectedGamepads: ControllerDeviceInfo[];
|
||||||
|
activeGamepadId: string | null;
|
||||||
|
controllerRawAxes: number[];
|
||||||
|
controllerRawButtons: ControllerButtonSnapshot[];
|
||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
|
||||||
@@ -82,6 +94,7 @@ export type RendererState = {
|
|||||||
chordPending: boolean;
|
chordPending: boolean;
|
||||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
keyboardDrivenModeEnabled: boolean;
|
keyboardDrivenModeEnabled: boolean;
|
||||||
|
keyboardSelectionVisible: boolean;
|
||||||
keyboardSelectedWordIndex: number | null;
|
keyboardSelectedWordIndex: number | null;
|
||||||
yomitanPopupVisible: boolean;
|
yomitanPopupVisible: boolean;
|
||||||
};
|
};
|
||||||
@@ -122,6 +135,15 @@ export function createRendererState(): RendererState {
|
|||||||
subsyncSourceTracks: [],
|
subsyncSourceTracks: [],
|
||||||
subsyncSubmitting: false,
|
subsyncSubmitting: false,
|
||||||
|
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
controllerDeviceSelectedIndex: 0,
|
||||||
|
controllerConfig: null,
|
||||||
|
connectedGamepads: [],
|
||||||
|
activeGamepadId: null,
|
||||||
|
controllerRawAxes: [],
|
||||||
|
controllerRawButtons: [],
|
||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
|
||||||
@@ -151,6 +173,7 @@ export function createRendererState(): RendererState {
|
|||||||
chordPending: false,
|
chordPending: false,
|
||||||
chordTimeout: null,
|
chordTimeout: null,
|
||||||
keyboardDrivenModeEnabled: false,
|
keyboardDrivenModeEnabled: false,
|
||||||
|
keyboardSelectionVisible: false,
|
||||||
keyboardSelectedWordIndex: null,
|
keyboardSelectedWordIndex: null,
|
||||||
yomitanPopupVisible: false,
|
yomitanPopupVisible: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ body {
|
|||||||
pointer-events: auto;
|
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 {
|
.overlay-error-toast {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
@@ -321,6 +349,12 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
pointer-events: none !important;
|
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 {
|
#subtitleRoot .c {
|
||||||
display: inline;
|
display: inline;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1013,6 +1047,10 @@ iframe[id^='yomitan-popup'] {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-options-list-entry {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-options-item {
|
.runtime-options-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1022,7 +1060,15 @@ iframe[id^='yomitan-popup'] {
|
|||||||
cursor: pointer;
|
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;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,6 +1076,11 @@ iframe[id^='yomitan-popup'] {
|
|||||||
background: rgba(100, 180, 255, 0.15);
|
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 {
|
.runtime-options-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -1055,12 +1106,84 @@ iframe[id^='yomitan-popup'] {
|
|||||||
color: #ff8f8f;
|
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 {
|
.session-help-content {
|
||||||
width: min(760px, 92%);
|
width: min(760px, 92%);
|
||||||
max-height: 84%;
|
max-height: 84%;
|
||||||
color: rgba(255, 255, 255, 0.95);
|
color: rgba(255, 255, 255, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.controller-debug-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.session-help-shortcut,
|
.session-help-shortcut,
|
||||||
.session-help-warning,
|
.session-help-warning,
|
||||||
.session-help-status {
|
.session-help-status {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type RendererDom = {
|
|||||||
subtitleRoot: HTMLElement;
|
subtitleRoot: HTMLElement;
|
||||||
subtitleContainer: HTMLElement;
|
subtitleContainer: HTMLElement;
|
||||||
overlay: HTMLElement;
|
overlay: HTMLElement;
|
||||||
|
controllerStatusToast: HTMLDivElement;
|
||||||
overlayErrorToast: HTMLDivElement;
|
overlayErrorToast: HTMLDivElement;
|
||||||
secondarySubContainer: HTMLElement;
|
secondarySubContainer: HTMLElement;
|
||||||
secondarySubRoot: HTMLElement;
|
secondarySubRoot: HTMLElement;
|
||||||
@@ -56,6 +57,23 @@ export type RendererDom = {
|
|||||||
subsyncRunButton: HTMLButtonElement;
|
subsyncRunButton: HTMLButtonElement;
|
||||||
subsyncStatus: HTMLDivElement;
|
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;
|
sessionHelpModal: HTMLDivElement;
|
||||||
sessionHelpClose: HTMLButtonElement;
|
sessionHelpClose: HTMLButtonElement;
|
||||||
sessionHelpShortcut: HTMLDivElement;
|
sessionHelpShortcut: HTMLDivElement;
|
||||||
@@ -78,6 +96,7 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||||
|
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||||
@@ -132,6 +151,23 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
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'),
|
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
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';
|
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 type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
@@ -12,6 +19,7 @@ export const IPC_CHANNELS = {
|
|||||||
toggleDevTools: 'toggle-dev-tools',
|
toggleDevTools: 'toggle-dev-tools',
|
||||||
toggleOverlay: 'toggle-overlay',
|
toggleOverlay: 'toggle-overlay',
|
||||||
saveSubtitlePosition: 'save-subtitle-position',
|
saveSubtitlePosition: 'save-subtitle-position',
|
||||||
|
saveControllerPreference: 'save-controller-preference',
|
||||||
setMecabEnabled: 'set-mecab-enabled',
|
setMecabEnabled: 'set-mecab-enabled',
|
||||||
mpvCommand: 'mpv-command',
|
mpvCommand: 'mpv-command',
|
||||||
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
||||||
@@ -32,6 +40,7 @@ export const IPC_CHANNELS = {
|
|||||||
getMecabStatus: 'get-mecab-status',
|
getMecabStatus: 'get-mecab-status',
|
||||||
getKeybindings: 'get-keybindings',
|
getKeybindings: 'get-keybindings',
|
||||||
getConfigShortcuts: 'get-config-shortcuts',
|
getConfigShortcuts: 'get-config-shortcuts',
|
||||||
|
getControllerConfig: 'get-controller-config',
|
||||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||||
focusMainWindow: 'focus-main-window',
|
focusMainWindow: 'focus-main-window',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ControllerPreferenceUpdate,
|
||||||
JimakuDownloadQuery,
|
JimakuDownloadQuery,
|
||||||
JimakuFilesQuery,
|
JimakuFilesQuery,
|
||||||
JimakuSearchQuery,
|
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 {
|
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
const { engine, sourceTrackId } = value;
|
const { engine, sourceTrackId } = value;
|
||||||
|
|||||||
128
src/types.ts
128
src/types.ts
@@ -375,6 +375,94 @@ export interface ShortcutsConfig {
|
|||||||
openJimaku?: string | null;
|
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 type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
export interface JimakuConfig {
|
export interface JimakuConfig {
|
||||||
@@ -487,6 +575,7 @@ export interface Config {
|
|||||||
websocket?: WebSocketConfig;
|
websocket?: WebSocketConfig;
|
||||||
annotationWebsocket?: AnnotationWebSocketConfig;
|
annotationWebsocket?: AnnotationWebSocketConfig;
|
||||||
texthooker?: TexthookerConfig;
|
texthooker?: TexthookerConfig;
|
||||||
|
controller?: ControllerConfig;
|
||||||
ankiConnect?: AnkiConnectConfig;
|
ankiConnect?: AnkiConnectConfig;
|
||||||
shortcuts?: ShortcutsConfig;
|
shortcuts?: ShortcutsConfig;
|
||||||
secondarySub?: SecondarySubConfig;
|
secondarySub?: SecondarySubConfig;
|
||||||
@@ -514,6 +603,21 @@ export interface ResolvedConfig {
|
|||||||
websocket: Required<WebSocketConfig>;
|
websocket: Required<WebSocketConfig>;
|
||||||
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
||||||
texthooker: Required<TexthookerConfig>;
|
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 & {
|
ankiConnect: AnkiConnectConfig & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -838,6 +942,8 @@ export interface ConfigHotReloadPayload {
|
|||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolvedControllerConfig = ResolvedConfig['controller'];
|
||||||
|
|
||||||
export interface SubtitleHoverTokenPayload {
|
export interface SubtitleHoverTokenPayload {
|
||||||
tokenIndex: number | null;
|
tokenIndex: number | null;
|
||||||
}
|
}
|
||||||
@@ -862,6 +968,8 @@ export interface ElectronAPI {
|
|||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
getKeybindings: () => Promise<Keybinding[]>;
|
getKeybindings: () => Promise<Keybinding[]>;
|
||||||
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
||||||
|
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
||||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||||
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||||
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||||
@@ -895,8 +1003,24 @@ export interface ElectronAPI {
|
|||||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
notifyOverlayModalClosed: (
|
||||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
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;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => 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