diff --git a/CHANGELOG.md b/CHANGELOG.md index 999efb2..c7ae0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.6.0 (2026-03-12) + +### Added +- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling. +- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection. +- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found. +- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes. + +### Internal +- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently + ## v0.5.6 (2026-03-10) ### Fixed diff --git a/backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md b/backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md new file mode 100644 index 0000000..ffda51d --- /dev/null +++ b/backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md @@ -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 + + + +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. + + + +## Acceptance Criteria + + + +- [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. + + + +## 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` diff --git a/changes/controller-overlay-support.md b/changes/controller-overlay-support.md new file mode 100644 index 0000000..894a4e0 --- /dev/null +++ b/changes/controller-overlay-support.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 29b79f4..a946f28 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -50,6 +50,55 @@ "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. + // ========================================== + // Controller Support + // Gamepad support for the visible overlay while keyboard-only mode is active. + // Use the selection modal to save a preferred controller by id for future launches. + // Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller. + // Override controller.buttonIndices when your pad reports non-standard raw button numbers. + // ========================================== + "controller": { + "enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false + "preferredGamepadId": "", // Preferred controller id saved from the controller selection modal. + "preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics. + "smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false + "scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input. + "horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input. + "stickDeadzone": 0.2, // Deadzone applied to controller stick axes. + "triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog + "triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode. + "repeatDelayMs": 320, // Delay before repeating held controller actions. + "repeatIntervalMs": 120, // Repeat interval for held controller actions. + "buttonIndices": { + "select": 6, // Raw button index used for the controller select/minus/back button. + "buttonSouth": 0, // Raw button index used for controller south/A button input. + "buttonEast": 1, // Raw button index used for controller east/B button input. + "buttonWest": 2, // Raw button index used for controller west/X button input. + "buttonNorth": 3, // Raw button index used for controller north/Y button input. + "leftShoulder": 4, // Raw button index used for controller left shoulder input. + "rightShoulder": 5, // Raw button index used for controller right shoulder input. + "leftStickPress": 9, // Raw button index used for controller L3 input. + "rightStickPress": 10, // Raw button index used for controller R3 input. + "leftTrigger": 6, // Raw button index used for controller L2 input. + "rightTrigger": 7 // Raw button index used for controller R2 input. + }, // Button indices setting. + "bindings": { + "toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY + "leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY + "rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY + "rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY + } // Bindings setting. + }, // Gamepad support for the visible overlay while keyboard-only mode is active. + // ========================================== // Startup Warmups // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 4f4701e..842286a 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,5 +1,14 @@ # Changelog +## v0.6.0 (2026-03-12) +- Added Chrome Gamepad API controller support for keyboard-only overlay mode. +- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation. +- Added smooth, slower popup scrolling for controller navigation. +- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection. +- Added a transient in-overlay controller-detected indicator when a controller is first found. +- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes. +- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently. + ## v0.5.6 (2026-03-10) - Persisted merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails. - Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of canonical `~/.config/SubMiner`. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 409e4fa..2f6550c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -95,6 +95,7 @@ The configuration file includes several main sections: - [**Keybindings**](#keybindings) - MPV command shortcuts - [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts +- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode - [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows - [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference - [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles @@ -503,6 +504,88 @@ Set any shortcut to `null` to disable it. Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled. +### Controller Support + +SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow. + +Important behavior: + +- Controller input is only active while keyboard-only mode is enabled. +- Keyboard-only mode continues to work normally without a controller. +- By default SubMiner uses the first connected controller. +- `Alt+C` opens the controller selection modal and saves the selected controller for future launches. +- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block. +- Turning keyboard-only mode off clears the keyboard-only token highlight state. +- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. + +```jsonc +{ + "controller": { + "enabled": true, + "preferredGamepadId": "", + "preferredGamepadLabel": "", + "smoothScroll": true, + "scrollPixelsPerSecond": 900, + "horizontalJumpPixels": 160, + "stickDeadzone": 0.2, + "triggerInputMode": "auto", + "triggerDeadzone": 0.5, + "repeatDelayMs": 320, + "repeatIntervalMs": 120, + "buttonIndices": { + "select": 6, + "buttonSouth": 0, + "buttonEast": 1, + "buttonWest": 2, + "buttonNorth": 3, + "leftShoulder": 4, + "rightShoulder": 5, + "leftStickPress": 9, + "rightStickPress": 10, + "leftTrigger": 6, + "rightTrigger": 7 + }, + "bindings": { + "toggleLookup": "buttonSouth", + "closeLookup": "buttonEast", + "toggleKeyboardOnlyMode": "buttonNorth", + "mineCard": "buttonWest", + "quitMpv": "select", + "previousAudio": "none", + "nextAudio": "rightShoulder", + "playCurrentAudio": "leftShoulder", + "toggleMpvPause": "leftStickPress", + "leftStickHorizontal": "leftStickX", + "leftStickVertical": "leftStickY", + "rightStickHorizontal": "rightStickX", + "rightStickVertical": "rightStickY" + } + } +} +``` + +Default logical mapping: + +- Left stick up/down: scroll Yomitan popup +- Left stick left/right: move subtitle token selection +- Right stick up/down: page-jump through Yomitan popup +- Right stick left/right: unused by default +- `A`: toggle lookup +- `B`: close lookup +- `Y`: toggle keyboard-only mode +- `X`: mine card +- `Minus` / `Select`: quit mpv +- `L1`: play current Yomitan audio (falls back to the first available track) +- `R1`: move to the next available Yomitan audio track +- `L3`: toggle mpv pause +- `L2` / `R2`: unbound by default + +If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default. + +If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal. + +Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field. + ### Manual Card Update Shortcuts When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 86bb2fe..0718c69 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -59,6 +59,22 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal 3. Yomitan detects the selection and opens its lookup popup. 4. From the popup, add the word to Anki. +### Controller Workflow + +With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard: + +1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words. +2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word. +3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps. +4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one. +5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`). +6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation. +7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time. + +The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller. + +See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options. + ## Creating Anki Cards There are three ways to create cards, depending on your workflow. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 29b79f4..a946f28 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -50,6 +50,55 @@ "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. + // ========================================== + // Controller Support + // Gamepad support for the visible overlay while keyboard-only mode is active. + // Use the selection modal to save a preferred controller by id for future launches. + // Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller. + // Override controller.buttonIndices when your pad reports non-standard raw button numbers. + // ========================================== + "controller": { + "enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false + "preferredGamepadId": "", // Preferred controller id saved from the controller selection modal. + "preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics. + "smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false + "scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input. + "horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input. + "stickDeadzone": 0.2, // Deadzone applied to controller stick axes. + "triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog + "triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode. + "repeatDelayMs": 320, // Delay before repeating held controller actions. + "repeatIntervalMs": 120, // Repeat interval for held controller actions. + "buttonIndices": { + "select": 6, // Raw button index used for the controller select/minus/back button. + "buttonSouth": 0, // Raw button index used for controller south/A button input. + "buttonEast": 1, // Raw button index used for controller east/B button input. + "buttonWest": 2, // Raw button index used for controller west/X button input. + "buttonNorth": 3, // Raw button index used for controller north/Y button input. + "leftShoulder": 4, // Raw button index used for controller left shoulder input. + "rightShoulder": 5, // Raw button index used for controller right shoulder input. + "leftStickPress": 9, // Raw button index used for controller L3 input. + "rightStickPress": 10, // Raw button index used for controller R3 input. + "leftTrigger": 6, // Raw button index used for controller L2 input. + "rightTrigger": 7 // Raw button index used for controller R2 input. + }, // Button indices setting. + "bindings": { + "toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger + "leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY + "leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY + "rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY + "rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY + } // Bindings setting. + }, // Gamepad support for the visible overlay while keyboard-only mode is active. + // ========================================== // Startup Warmups // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 79ba27c..1f74542 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -69,6 +69,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +## Controller Shortcuts + +These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration. + +| Shortcut | Action | Configurable | +| ------------- | ------------------------------ | ------------ | +| `Alt+C` | Open controller selection modal | Fixed | +| `Alt+Shift+C` | Open controller debug modal | Fixed | + +Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller. + ## MPV Plugin Chords When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second. diff --git a/docs-site/usage.md b/docs-site/usage.md index 816513b..5c56753 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -246,6 +246,45 @@ Notes: - `--whisper-threads` - `--yt-subgen-audio-format` +## Controller Support + +SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled. + +### Getting Started + +1. Connect a controller before or after launching SubMiner. +2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. +3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup. +4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. + +By default SubMiner uses the first connected controller. Press `Alt+C` in the overlay to open the controller selection modal and persist your preferred controller across sessions. Press `Alt+Shift+C` to open a live debug modal showing raw axes and button values. + +### Default Button Mapping + +| Button | Action | +| ------ | ------ | +| `A` (South) | Toggle lookup | +| `B` (East) | Close lookup | +| `Y` (North) | Toggle keyboard-only mode | +| `X` (West) | Mine card | +| `L1` | Play current Yomitan audio | +| `R1` | Next Yomitan audio track | +| `L3` (left stick press) | Toggle mpv pause | +| `Select` / `Minus` | Quit mpv | +| `L2` / `R2` | Unbound (available for custom bindings) | + +### Analog Controls + +| Input | Action | +| ----- | ------ | +| Left stick horizontal | Move token selection left/right | +| Left stick vertical | Smooth scroll Yomitan popup | +| Right stick horizontal | Jump inside popup (horizontal) | +| Right stick vertical | Smooth scroll popup (vertical) | +| D-pad | Fallback for stick navigation | + +All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options. + ## Keybindings See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization. diff --git a/docs/plans/2026-03-11-overlay-controller-support-design.md b/docs/plans/2026-03-11-overlay-controller-support-design.md new file mode 100644 index 0000000..939811d --- /dev/null +++ b/docs/plans/2026-03-11-overlay-controller-support-design.md @@ -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. + diff --git a/docs/plans/2026-03-11-overlay-controller-support.md b/docs/plans/2026-03-11-overlay-controller-support.md new file mode 100644 index 0000000..5a2afb7 --- /dev/null +++ b/docs/plans/2026-03-11-overlay-controller-support.md @@ -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`. diff --git a/package.json b/package.json index 1fca685..da4af6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subminer", - "version": "0.5.6", + "version": "0.6.0", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 9b42abe..1e609d3 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1106,6 +1106,135 @@ test('parses global shortcuts and startup settings', () => { assert.equal(config.youtubeSubgen.fixWithAi, true); }); +test('parses controller settings with logical bindings and tuning knobs', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "enabled": true, + "preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)", + "preferredGamepadLabel": "Xbox Wireless Controller", + "smoothScroll": false, + "scrollPixelsPerSecond": 1440, + "horizontalJumpPixels": 180, + "stickDeadzone": 0.3, + "triggerInputMode": "analog", + "triggerDeadzone": 0.4, + "repeatDelayMs": 220, + "repeatIntervalMs": 70, + "buttonIndices": { + "select": 6, + "leftStickPress": 9, + "rightStickPress": 10 + }, + "bindings": { + "toggleLookup": "buttonWest", + "closeLookup": "buttonEast", + "toggleKeyboardOnlyMode": "buttonNorth", + "mineCard": "buttonSouth", + "quitMpv": "select", + "previousAudio": "leftShoulder", + "nextAudio": "rightShoulder", + "playCurrentAudio": "none", + "toggleMpvPause": "leftStickPress", + "leftStickHorizontal": "rightStickX", + "leftStickVertical": "rightStickY", + "rightStickHorizontal": "leftStickX", + "rightStickVertical": "leftStickY" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.controller.enabled, true); + assert.equal( + config.controller.preferredGamepadId, + 'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)', + ); + assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller'); + assert.equal(config.controller.smoothScroll, false); + assert.equal(config.controller.scrollPixelsPerSecond, 1440); + assert.equal(config.controller.horizontalJumpPixels, 180); + assert.equal(config.controller.stickDeadzone, 0.3); + assert.equal(config.controller.triggerInputMode, 'analog'); + assert.equal(config.controller.triggerDeadzone, 0.4); + assert.equal(config.controller.repeatDelayMs, 220); + assert.equal(config.controller.repeatIntervalMs, 70); + assert.equal(config.controller.buttonIndices.select, 6); + assert.equal(config.controller.buttonIndices.leftStickPress, 9); + assert.equal(config.controller.bindings.toggleLookup, 'buttonWest'); + assert.equal(config.controller.bindings.quitMpv, 'select'); + assert.equal(config.controller.bindings.playCurrentAudio, 'none'); + assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress'); + assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX'); + assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY'); +}); + +test('controller positive-number tuning rejects sub-unit values that floor to zero', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "scrollPixelsPerSecond": 0.5, + "horizontalJumpPixels": 0.2, + "repeatDelayMs": 0.9, + "repeatIntervalMs": 0.1 + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond); + assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels); + assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs); + assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs); + assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true); + assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true); + assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true); + assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true); +}); + +test('controller button index config rejects fractional values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "buttonIndices": { + "select": 6.5, + "leftStickPress": 9.1 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select); + assert.equal( + config.controller.buttonIndices.leftStickPress, + DEFAULT_CONFIG.controller.buttonIndices.leftStickPress, + ); + assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true); + assert.equal( + warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'), + true, + ); +}); + test('runtime options registry is centralized', () => { const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); assert.deepEqual(ids, [ @@ -1638,6 +1767,7 @@ test('template generator includes known keys', () => { const output = generateConfigTemplate(DEFAULT_CONFIG); assert.match(output, /"ai":/); assert.match(output, /"ankiConnect":/); + assert.match(output, /"controller":/); assert.match(output, /"logging":/); assert.match(output, /"websocket":/); assert.match(output, /"discordPresence":/); @@ -1662,6 +1792,14 @@ test('template generator includes known keys', () => { output, /"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/, ); + assert.match( + output, + /"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./, + ); + assert.match( + output, + /"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/, + ); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); assert.match( output, diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 9cefaba..12e1b46 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -25,6 +25,7 @@ const { annotationWebsocket, logging, texthooker, + controller, shortcuts, secondarySub, subsync, @@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { annotationWebsocket, logging, texthooker, + controller, ankiConnect, shortcuts, secondarySub, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index b569a86..fdaaa1f 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick< | 'annotationWebsocket' | 'logging' | 'texthooker' + | 'controller' | 'shortcuts' | 'secondarySub' | 'subsync' @@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick< launchAtStartup: true, openBrowser: true, }, + controller: { + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 900, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 320, + repeatIntervalMs: 120, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'none', + nextAudio: 'rightShoulder', + playCurrentAudio: 'leftShoulder', + toggleMpvPause: 'leftStickPress', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }, shortcuts: { toggleVisibleOverlayGlobal: 'Alt+Shift+O', copySubtitle: 'CommandOrControl+C', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index eedfbe7..a7859e3 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -19,6 +19,8 @@ test('config option registry includes critical paths and has unique entries', () for (const requiredPath of [ 'logging.level', 'annotationWebsocket.enabled', + 'controller.enabled', + 'controller.scrollPixelsPerSecond', 'startupWarmups.lowPowerMode', 'subtitleStyle.enableJlpt', 'subtitleStyle.autoPauseVideoOnYomitanPopup', @@ -38,6 +40,7 @@ test('config template sections include expected domains and unique keys', () => const requiredKeys: (typeof keys)[number][] = [ 'websocket', 'annotationWebsocket', + 'controller', 'startupWarmups', 'subtitleStyle', 'ankiConnect', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index cce31a2..36f009c 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared'; export function buildCoreConfigOptionRegistry( defaultConfig: ResolvedConfig, ): ConfigOptionRegistryEntry[] { + const controllerButtonEnumValues = [ + 'none', + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', + ]; + return [ { path: 'logging.level', @@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.logging.level, description: 'Minimum log level for runtime logging.', }, + { + path: 'controller.enabled', + kind: 'boolean', + defaultValue: defaultConfig.controller.enabled, + description: 'Enable overlay controller support through the Chrome Gamepad API.', + }, + { + path: 'controller.preferredGamepadId', + kind: 'string', + defaultValue: defaultConfig.controller.preferredGamepadId, + description: 'Preferred controller id saved from the controller selection modal.', + }, + { + path: 'controller.preferredGamepadLabel', + kind: 'string', + defaultValue: defaultConfig.controller.preferredGamepadLabel, + description: 'Preferred controller display label saved for diagnostics.', + }, + { + path: 'controller.smoothScroll', + kind: 'boolean', + defaultValue: defaultConfig.controller.smoothScroll, + description: 'Use smooth scrolling for controller-driven popup scroll input.', + }, + { + path: 'controller.scrollPixelsPerSecond', + kind: 'number', + defaultValue: defaultConfig.controller.scrollPixelsPerSecond, + description: 'Base popup scroll speed for controller stick input.', + }, + { + path: 'controller.horizontalJumpPixels', + kind: 'number', + defaultValue: defaultConfig.controller.horizontalJumpPixels, + description: 'Popup page-jump distance for controller jump input.', + }, + { + path: 'controller.stickDeadzone', + kind: 'number', + defaultValue: defaultConfig.controller.stickDeadzone, + description: 'Deadzone applied to controller stick axes.', + }, + { + path: 'controller.triggerInputMode', + kind: 'enum', + enumValues: ['auto', 'digital', 'analog'], + defaultValue: defaultConfig.controller.triggerInputMode, + description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.', + }, + { + path: 'controller.triggerDeadzone', + kind: 'number', + defaultValue: defaultConfig.controller.triggerDeadzone, + description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.', + }, + { + path: 'controller.repeatDelayMs', + kind: 'number', + defaultValue: defaultConfig.controller.repeatDelayMs, + description: 'Delay before repeating held controller actions.', + }, + { + path: 'controller.repeatIntervalMs', + kind: 'number', + defaultValue: defaultConfig.controller.repeatIntervalMs, + description: 'Repeat interval for held controller actions.', + }, + { + path: 'controller.buttonIndices.select', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.select, + description: 'Raw button index used for the controller select/minus/back button.', + }, + { + path: 'controller.buttonIndices.buttonSouth', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.buttonSouth, + description: 'Raw button index used for controller south/A button input.', + }, + { + path: 'controller.buttonIndices.buttonEast', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.buttonEast, + description: 'Raw button index used for controller east/B button input.', + }, + { + path: 'controller.buttonIndices.buttonNorth', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.buttonNorth, + description: 'Raw button index used for controller north/Y button input.', + }, + { + path: 'controller.buttonIndices.buttonWest', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.buttonWest, + description: 'Raw button index used for controller west/X button input.', + }, + { + path: 'controller.buttonIndices.leftShoulder', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.leftShoulder, + description: 'Raw button index used for controller left shoulder input.', + }, + { + path: 'controller.buttonIndices.rightShoulder', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.rightShoulder, + description: 'Raw button index used for controller right shoulder input.', + }, + { + path: 'controller.buttonIndices.leftStickPress', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.leftStickPress, + description: 'Raw button index used for controller L3 input.', + }, + { + path: 'controller.buttonIndices.rightStickPress', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.rightStickPress, + description: 'Raw button index used for controller R3 input.', + }, + { + path: 'controller.buttonIndices.leftTrigger', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.leftTrigger, + description: 'Raw button index used for controller L2 input.', + }, + { + path: 'controller.buttonIndices.rightTrigger', + kind: 'number', + defaultValue: defaultConfig.controller.buttonIndices.rightTrigger, + description: 'Raw button index used for controller R2 input.', + }, + { + path: 'controller.bindings.toggleLookup', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.toggleLookup, + description: 'Controller binding for toggling lookup.', + }, + { + path: 'controller.bindings.closeLookup', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.closeLookup, + description: 'Controller binding for closing lookup.', + }, + { + path: 'controller.bindings.toggleKeyboardOnlyMode', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode, + description: 'Controller binding for toggling keyboard-only mode.', + }, + { + path: 'controller.bindings.mineCard', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.mineCard, + description: 'Controller binding for mining the active card.', + }, + { + path: 'controller.bindings.quitMpv', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.quitMpv, + description: 'Controller binding for quitting mpv.', + }, + { + path: 'controller.bindings.previousAudio', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.previousAudio, + description: 'Controller binding for previous Yomitan audio.', + }, + { + path: 'controller.bindings.nextAudio', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.nextAudio, + description: 'Controller binding for next Yomitan audio.', + }, + { + path: 'controller.bindings.playCurrentAudio', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.playCurrentAudio, + description: 'Controller binding for playing the current Yomitan audio.', + }, + { + path: 'controller.bindings.toggleMpvPause', + kind: 'enum', + enumValues: controllerButtonEnumValues, + defaultValue: defaultConfig.controller.bindings.toggleMpvPause, + description: 'Controller binding for toggling mpv play/pause.', + }, + { + path: 'controller.bindings.leftStickHorizontal', + kind: 'enum', + enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], + defaultValue: defaultConfig.controller.bindings.leftStickHorizontal, + description: 'Axis binding used for left/right token selection.', + }, + { + path: 'controller.bindings.leftStickVertical', + kind: 'enum', + enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], + defaultValue: defaultConfig.controller.bindings.leftStickVertical, + description: 'Axis binding used for primary popup scrolling.', + }, + { + path: 'controller.bindings.rightStickHorizontal', + kind: 'enum', + enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], + defaultValue: defaultConfig.controller.bindings.rightStickHorizontal, + description: 'Axis binding reserved for alternate right-stick mappings.', + }, + { + path: 'controller.bindings.rightStickVertical', + kind: 'enum', + enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'], + defaultValue: defaultConfig.controller.bindings.rightStickVertical, + description: 'Axis binding used for popup page jumps.', + }, { path: 'texthooker.launchAtStartup', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 9c0dfd9..93554c5 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], key: 'logging', }, + { + title: 'Controller Support', + description: [ + 'Gamepad support for the visible overlay while keyboard-only mode is active.', + 'Use the selection modal to save a preferred controller by id for future launches.', + 'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.', + 'Override controller.buttonIndices when your pad reports non-standard raw button numbers.', + ], + key: 'controller', + }, { title: 'Startup Warmups', description: [ diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 0cbe90e..0fa1719 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyCoreDomainConfig(context: ResolveContext): void { const { src, resolved, warn } = context; + const controllerButtonBindings = [ + 'none', + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', + ] as const; + const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const; if (isObject(src.texthooker)) { const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); @@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void { } } + if (isObject(src.controller)) { + const enabled = asBoolean(src.controller.enabled); + if (enabled !== undefined) { + resolved.controller.enabled = enabled; + } else if (src.controller.enabled !== undefined) { + warn( + 'controller.enabled', + src.controller.enabled, + resolved.controller.enabled, + 'Expected boolean.', + ); + } + + const preferredGamepadId = asString(src.controller.preferredGamepadId); + if (preferredGamepadId !== undefined) { + resolved.controller.preferredGamepadId = preferredGamepadId; + } + + const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel); + if (preferredGamepadLabel !== undefined) { + resolved.controller.preferredGamepadLabel = preferredGamepadLabel; + } + + const smoothScroll = asBoolean(src.controller.smoothScroll); + if (smoothScroll !== undefined) { + resolved.controller.smoothScroll = smoothScroll; + } else if (src.controller.smoothScroll !== undefined) { + warn( + 'controller.smoothScroll', + src.controller.smoothScroll, + resolved.controller.smoothScroll, + 'Expected boolean.', + ); + } + + const triggerInputMode = asString(src.controller.triggerInputMode); + if ( + triggerInputMode === 'auto' || + triggerInputMode === 'digital' || + triggerInputMode === 'analog' + ) { + resolved.controller.triggerInputMode = triggerInputMode; + } else if (src.controller.triggerInputMode !== undefined) { + warn( + 'controller.triggerInputMode', + src.controller.triggerInputMode, + resolved.controller.triggerInputMode, + "Expected 'auto', 'digital', or 'analog'.", + ); + } + + const boundedNumberKeys = [ + 'scrollPixelsPerSecond', + 'horizontalJumpPixels', + 'repeatDelayMs', + 'repeatIntervalMs', + ] as const; + for (const key of boundedNumberKeys) { + const value = asNumber(src.controller[key]); + if (value !== undefined && Math.floor(value) > 0) { + resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key]; + } else if (src.controller[key] !== undefined) { + warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.'); + } + } + + const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const; + for (const key of deadzoneKeys) { + const value = asNumber(src.controller[key]); + if (value !== undefined && value >= 0 && value <= 1) { + resolved.controller[key] = value as (typeof resolved.controller)[typeof key]; + } else if (src.controller[key] !== undefined) { + warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.'); + } + } + + if (isObject(src.controller.buttonIndices)) { + const buttonIndexKeys = [ + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', + ] as const; + + for (const key of buttonIndexKeys) { + const value = asNumber(src.controller.buttonIndices[key]); + if (value !== undefined && value >= 0 && Number.isInteger(value)) { + resolved.controller.buttonIndices[key] = value; + } else if (src.controller.buttonIndices[key] !== undefined) { + warn( + `controller.buttonIndices.${key}`, + src.controller.buttonIndices[key], + resolved.controller.buttonIndices[key], + 'Expected non-negative integer.', + ); + } + } + } + + if (isObject(src.controller.bindings)) { + const buttonBindingKeys = [ + 'toggleLookup', + 'closeLookup', + 'toggleKeyboardOnlyMode', + 'mineCard', + 'quitMpv', + 'previousAudio', + 'nextAudio', + 'playCurrentAudio', + 'toggleMpvPause', + ] as const; + + for (const key of buttonBindingKeys) { + const value = asString(src.controller.bindings[key]); + if ( + value !== undefined && + controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number]) + ) { + resolved.controller.bindings[key] = + value as (typeof resolved.controller.bindings)[typeof key]; + } else if (src.controller.bindings[key] !== undefined) { + warn( + `controller.bindings.${key}`, + src.controller.bindings[key], + resolved.controller.bindings[key], + `Expected one of: ${controllerButtonBindings.join(', ')}.`, + ); + } + } + + const axisBindingKeys = [ + 'leftStickHorizontal', + 'leftStickVertical', + 'rightStickHorizontal', + 'rightStickVertical', + ] as const; + + for (const key of axisBindingKeys) { + const value = asString(src.controller.bindings[key]); + if ( + value !== undefined && + controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number]) + ) { + resolved.controller.bindings[key] = + value as (typeof resolved.controller.bindings)[typeof key]; + } else if (src.controller.bindings[key] !== undefined) { + warn( + `controller.bindings.${key}`, + src.controller.bindings[key], + resolved.controller.bindings[key], + `Expected one of: ${controllerAxisBindings.join(', ')}.`, + ); + } + } + } + } + if (Array.isArray(src.keybindings)) { resolved.keybindings = src.keybindings.filter( (entry): entry is { key: string; command: (string | number)[] | null } => { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index fac8ff5..37287da 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -53,6 +53,48 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getControllerConfig: () => ({ + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'leftShoulder', + nextAudio: 'rightShoulder', + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }), + saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover', getMpvClient: () => null, focusMainWindow: () => {}, @@ -117,6 +159,48 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getControllerConfig: () => ({ + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'leftShoulder', + nextAudio: 'rightShoulder', + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }), + saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', focusMainWindow: () => {}, @@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused); assert.ok(getPlaybackPausedHandler); assert.equal(getPlaybackPausedHandler!({}), null); + + const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig); + assert.ok(getControllerConfigHandler); + assert.equal( + (getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond, + 960, + ); }); test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { const { registrar, handlers } = createFakeIpcRegistrar(); const saves: unknown[] = []; + const controllerSaves: unknown[] = []; const closedModals: unknown[] = []; const openedModals: unknown[] = []; registerIpcHandlers( @@ -207,6 +299,50 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getControllerConfig: () => ({ + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'leftShoulder', + nextAudio: 'rightShoulder', + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }), + saveControllerPreference: (update) => { + controllerSaves.push(update); + }, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', focusMainWindow: () => {}, @@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options'); assert.deepEqual(openedModals, ['subsync', 'runtime-options']); }); + +test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const controllerSaves: unknown[] = []; + registerIpcHandlers( + { + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleDevTools: () => {}, + getVisibleOverlayVisibility: () => false, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => false, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabStatus: () => ({ available: false, enabled: false, path: null }), + setMecabEnabled: () => {}, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getControllerConfig: () => ({ + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'leftShoulder', + nextAudio: 'rightShoulder', + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }), + saveControllerPreference: async (update) => { + await Promise.resolve(); + controllerSaves.push(update); + }, + getSecondarySubMode: () => 'hover', + getCurrentSecondarySub: () => '', + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + getAnilistStatus: () => ({}), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + registrar, + ); + + const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference); + assert.ok(saveHandler); + + await assert.rejects( + async () => { + await saveHandler!({}, { preferredGamepadId: 12 }); + }, + /Invalid controller preference payload/, + ); + await saveHandler!({}, { + preferredGamepadId: 'pad-1', + preferredGamepadLabel: 'Pad 1', + }); + + assert.deepEqual(controllerSaves, [ + { + preferredGamepadId: 'pad-1', + preferredGamepadLabel: 'Pad 1', + }, + ]); +}); + +test('registerIpcHandlers rejects malformed controller preference payloads', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + registerIpcHandlers( + { + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleDevTools: () => {}, + getVisibleOverlayVisibility: () => false, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => false, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabStatus: () => ({ available: false, enabled: false, path: null }), + setMecabEnabled: () => {}, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getControllerConfig: () => ({ + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'leftShoulder', + nextAudio: 'rightShoulder', + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }), + saveControllerPreference: async () => {}, + getSecondarySubMode: () => 'hover', + getCurrentSecondarySub: () => '', + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + getAnilistStatus: () => ({}), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + registrar, + ); + + const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference); + await assert.rejects( + async () => { + await saveHandler!({}, { preferredGamepadId: 12 }); + }, + /Invalid controller preference payload/, + ); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 070aa44..9cf7fdd 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -1,6 +1,8 @@ import electron from 'electron'; import type { IpcMainEvent } from 'electron'; import type { + ControllerPreferenceUpdate, + ResolvedControllerConfig, RuntimeOptionId, RuntimeOptionValue, SubtitlePosition, @@ -10,6 +12,7 @@ import type { import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { parseMpvCommand, + parseControllerPreferenceUpdate, parseOptionalForwardingOptions, parseOverlayHostedModal, parseRuntimeOptionDirection, @@ -45,6 +48,8 @@ export interface IpcServiceDeps { handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; + getControllerConfig: () => ResolvedControllerConfig; + saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getCurrentSecondarySub: () => string; focusMainWindow: () => void; @@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions { handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; + getControllerConfig: () => ResolvedControllerConfig; + saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; @@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService handleMpvCommand: options.handleMpvCommand, getKeybindings: options.getKeybindings, getConfiguredShortcuts: options.getConfiguredShortcuts, + getControllerConfig: options.getControllerConfig, + saveControllerPreference: options.saveControllerPreference, getSecondarySubMode: options.getSecondarySubMode, getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '', focusMainWindow: () => { @@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.saveSubtitlePosition(parsedPosition); }); + ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => { + const parsedUpdate = parseControllerPreferenceUpdate(update); + if (!parsedUpdate) { + throw new Error('Invalid controller preference payload'); + } + await deps.saveControllerPreference(parsedUpdate); + }); + ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { return deps.getMecabStatus(); }); @@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getConfiguredShortcuts(); }); + ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => { + return deps.getControllerConfig(); + }); + ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => { return deps.getSecondarySubMode(); }); diff --git a/src/main.ts b/src/main.ts index ce678fb..56157c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -358,7 +358,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; -import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime'; +import { createOverlayModalRuntimeService } from './main/overlay-runtime'; +import type { OverlayHostedModal } from './shared/ipc/contracts'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createFrequencyDictionaryRuntimeService, @@ -3407,6 +3408,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getMecabTokenizer: () => appState.mecabTokenizer, getKeybindings: () => appState.keybindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), + getControllerConfig: () => getResolvedConfig().controller, + saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { + configService.patchRawConfig({ + controller: { + preferredGamepadId, + preferredGamepadLabel, + }, + }); + }, getSecondarySubMode: () => appState.secondarySubMode, getMpvClient: () => appState.mpvClient, getAnkiConnectStatus: () => appState.ankiIntegration !== null, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 4c215a1..d5d40bc 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams { handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; + getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; + saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; getMpvClient: IpcDepsRuntimeOptions['getMpvClient']; runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual']; @@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps( handleMpvCommand: params.handleMpvCommand, getKeybindings: params.getKeybindings, getConfiguredShortcuts: params.getConfiguredShortcuts, + getControllerConfig: params.getControllerConfig, + saveControllerPreference: params.saveControllerPreference, focusMainWindow: params.focusMainWindow ?? (() => {}), getSecondarySubMode: params.getSecondarySubMode, getMpvClient: params.getMpvClient, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 207c3a9..366862b 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -1,10 +1,9 @@ import type { BrowserWindow } from 'electron'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; -type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku'; - export interface OverlayWindowResolver { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null; @@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService( getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }; } - -export type { OverlayHostedModal }; diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 92eb08e..4665e9e 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getMecabTokenizer: () => null, getKeybindings: () => [], getConfiguredShortcuts: () => ({}) as never, + getControllerConfig: () => ({}) as never, + saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover' as never, getMpvClient: () => null, getAnkiConnectStatus: () => false, diff --git a/src/main/runtime/overlay-main-actions.ts b/src/main/runtime/overlay-main-actions.ts index e7fe1af..fcfb0f8 100644 --- a/src/main/runtime/overlay-main-actions.ts +++ b/src/main/runtime/overlay-main-actions.ts @@ -1,4 +1,4 @@ -import type { OverlayHostedModal } from '../overlay-runtime'; +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue'; export function createSetOverlayVisibleHandler(deps: { diff --git a/src/main/runtime/overlay-runtime-main-actions.ts b/src/main/runtime/overlay-runtime-main-actions.ts index 108921a..f1b0152 100644 --- a/src/main/runtime/overlay-runtime-main-actions.ts +++ b/src/main/runtime/overlay-runtime-main-actions.ts @@ -1,5 +1,5 @@ import type { RuntimeOptionState } from '../../types'; -import type { OverlayHostedModal } from '../overlay-runtime'; +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; type RuntimeOptionsManagerLike = { listOptions: () => RuntimeOptionState[]; diff --git a/src/preload.ts b/src/preload.ts index c2862cd..7b0457a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -48,6 +48,8 @@ import type { OverlayContentMeasurement, ShortcutsConfig, ConfigHotReloadPayload, + ControllerPreferenceUpdate, + ResolvedControllerConfig, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), getConfiguredShortcuts: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), + getControllerConfig: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), + saveControllerPreference: (update: ControllerPreferenceUpdate): Promise => + ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update), getJimakuMediaInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), @@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = { onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, appendClipboardVideoToQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), - notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { + notifyOverlayModalClosed: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, - notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { + notifyOverlayModalOpened: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal); }, reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { diff --git a/src/renderer/controller-status-indicator.test.ts b/src/renderer/controller-status-indicator.test.ts new file mode 100644 index 0000000..341cb27 --- /dev/null +++ b/src/renderer/controller-status-indicator.test.ts @@ -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 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); +}); diff --git a/src/renderer/controller-status-indicator.ts b/src/renderer/controller-status-indicator.ts new file mode 100644 index 0000000..b11d336 --- /dev/null +++ b/src/renderer/controller-status-indicator.ts @@ -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; + clearTimeout?: (timer: ReturnType | 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 | number) => + globalThis.clearTimeout(timer as ReturnType)); + let hideTimeout: ReturnType | number | null = null; + let previousConnectedIds = new Set(); + + 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 }; +} diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts new file mode 100644 index 0000000..9b472df --- /dev/null +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -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> = {}, +): 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, 'bindings' | 'buttonIndices'> & { + bindings?: Partial; + buttonIndices?: Partial; + } = {}, +): 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']); +}); diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts new file mode 100644 index 0000000..3302d83 --- /dev/null +++ b/src/renderer/handlers/gamepad-controller.ts @@ -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; + 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, number> = { + select: 8, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, +}; + +const AXIS_INDEX_BY_BINDING: Record = { + leftStickX: 0, + leftStickY: 1, + rightStickX: 3, + rightStickY: 4, +}; + +const DPAD_BUTTON_INDEX = { + up: 12, + down: 13, + left: 14, + right: 15, +} as const; +const DPAD_AXIS_INDEX = { + horizontal: 6, + vertical: 7, +} as const; + +function isTriggerBinding(binding: ControllerButtonBinding): boolean { + return binding === 'leftTrigger' || binding === 'rightTrigger'; +} + +function resolveButtonIndex( + config: ResolvedControllerConfig, + binding: ControllerButtonBinding, +): number { + if (binding === 'none') { + return -1; + } + return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding]; +} + +function normalizeButtonState( + gamepad: GamepadLike, + config: ResolvedControllerConfig, + binding: ControllerButtonBinding, + triggerInputMode: ControllerTriggerInputMode, + triggerDeadzone: number, +): boolean { + if (binding === 'none') { + return false; + } + const button = gamepad.buttons[resolveButtonIndex(config, binding)]; + if (isTriggerBinding(binding)) { + return normalizeTriggerState(button, triggerInputMode, triggerDeadzone); + } + return normalizeRawButtonState(button, triggerDeadzone); +} + +function normalizeRawButtonState( + button: ControllerButtonState | undefined, + triggerDeadzone: number, +): 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[] { + 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(); + 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([ + 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, + }; +} diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index c58120f..86dd5d3 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -3,7 +3,10 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; -import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js'; +import { + YOMITAN_POPUP_COMMAND_EVENT, + YOMITAN_POPUP_HIDDEN_EVENT, +} from '../yomitan-popup.js'; type CommandEventDetail = { type?: string; @@ -11,6 +14,9 @@ type CommandEventDetail = { key?: string; code?: string; repeat?: boolean; + direction?: number; + deltaX?: number; + deltaY?: number; }; function createClassList() { @@ -44,9 +50,12 @@ function installKeyboardTestGlobals() { const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent; const documentListeners = new Map void>>(); + const windowListeners = new Map void>>(); const commandEvents: CommandEventDetail[] = []; const mpvCommands: Array> = []; let playbackPausedResponse: boolean | null = false; + let selectionClearCount = 0; + let selectionAddCount = 0; let popupVisible = false; @@ -60,8 +69,12 @@ function installKeyboardTestGlobals() { }; const selection = { - removeAllRanges: () => {}, - addRange: () => {}, + removeAllRanges: () => { + selectionClearCount += 1; + }, + addRange: () => { + selectionAddCount += 1; + }, }; const overlayFocusCalls: Array<{ preventScroll?: boolean }> = []; @@ -96,12 +109,20 @@ function installKeyboardTestGlobals() { Object.defineProperty(globalThis, 'window', { configurable: true, value: { - addEventListener: () => {}, + addEventListener: (type: string, listener: (event: unknown) => void) => { + const listeners = windowListeners.get(type) ?? []; + listeners.push(listener); + windowListeners.set(type, listeners); + }, dispatchEvent: (event: Event) => { if (event.type === YOMITAN_POPUP_COMMAND_EVENT) { const detail = (event as Event & { detail?: CommandEventDetail }).detail; commandEvents.push(detail ?? {}); } + const listeners = windowListeners.get(event.type) ?? []; + for (const listener of listeners) { + listener(event); + } return true; }, getComputedStyle: () => ({ @@ -192,6 +213,13 @@ function installKeyboardTestGlobals() { } } + function dispatchWindowEvent(type: string): void { + const listeners = windowListeners.get(type) ?? []; + for (const listener of listeners) { + listener(new Event(type)); + } + } + function restore() { Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); @@ -224,6 +252,7 @@ function installKeyboardTestGlobals() { windowFocusCalls: () => windowFocusCalls, dispatchKeydown, dispatchFocusInOnPopup, + dispatchWindowEvent, setPopupVisible: (value: boolean) => { popupVisible = value; }, @@ -231,6 +260,8 @@ function installKeyboardTestGlobals() { setPlaybackPausedResponse: (value: boolean | null) => { playbackPausedResponse = value; }, + selectionClearCount: () => selectionClearCount, + selectionAddCount: () => selectionAddCount, restore, }; } @@ -238,6 +269,9 @@ function installKeyboardTestGlobals() { function createKeyboardHandlerHarness() { const testGlobals = installKeyboardTestGlobals(); const subtitleRootClassList = createClassList(); + let controllerSelectOpenCount = 0; + let controllerDebugOpenCount = 0; + let controllerSelectKeydownCount = 0; const createWordNode = (left: number) => ({ classList: createClassList(), @@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() { handleSubsyncKeydown: () => false, handleKikuKeydown: () => false, handleJimakuKeydown: () => false, + handleControllerSelectKeydown: () => { + controllerSelectKeydownCount += 1; + return true; + }, + handleControllerDebugKeydown: () => false, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, getPlaybackPaused: () => testGlobals.getPlaybackPaused(), + openControllerSelectModal: () => { + controllerSelectOpenCount += 1; + }, + openControllerDebugModal: () => { + controllerDebugOpenCount += 1; + }, }); return { ctx, handlers, testGlobals, + controllerSelectOpenCount: () => controllerSelectOpenCount, + controllerDebugOpenCount: () => controllerDebugOpenCount, + controllerSelectKeydownCount: () => controllerSelectKeydownCount, setWordCount: (count: number) => { wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); }, @@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is } }); +test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + assert.equal(handlers.playCurrentAudioForController(), true); + assert.equal(handlers.cyclePopupAudioSourceForController(1), true); + assert.equal(handlers.scrollPopupByController(48, -24), true); + + assert.deepEqual( + testGlobals.commandEvents.slice(-3), + [ + { type: 'playCurrentAudio' }, + { type: 'cycleAudioSource', direction: 1 }, + { type: 'scrollBy', deltaX: 48, deltaY: -24 }, + ], + ); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => { + const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + + testGlobals.dispatchKeydown({ + key: 'C', + code: 'KeyC', + altKey: true, + shiftKey: true, + }); + + assert.equal(controllerDebugOpenCount(), 1); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => { + const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + ctx.state.yomitanPopupVisible = true; + + testGlobals.dispatchKeydown({ + key: 'C', + code: 'KeyC', + altKey: true, + shiftKey: true, + }); + + assert.equal(controllerDebugOpenCount(), 1); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => { + const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + ctx.state.controllerSelectModalOpen = true; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); + + assert.equal(controllerSelectKeydownCount(), 1); + assert.equal( + testGlobals.commandEvents.some( + (event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown', + ), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: h moves left when popup is closed', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () = } }); +test('keyboard mode: turning mode off clears selected token highlight', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + const wordNodes = ctx.dom.subtitleRoot.querySelectorAll(); + assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true); + + handlers.handleKeyboardModeToggleRequested(); + + assert.equal(ctx.state.keyboardDrivenModeEnabled, false); + assert.equal(ctx.state.keyboardSelectedWordIndex, null); + assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + handlers.syncKeyboardTokenSelection(); + + const wordNodes = ctx.dom.subtitleRoot.querySelectorAll(); + assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true); + + handlers.handleKeyboardModeToggleRequested(); + ctx.state.yomitanPopupVisible = false; + testGlobals.setPopupVisible(false); + testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT); + + assert.equal(ctx.state.keyboardDrivenModeEnabled, false); + assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + + const wordNodes = ctx.dom.subtitleRoot.querySelectorAll(); + assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true); + assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false); + + handlers.handleLookupWindowToggleRequested(); + await wait(0); + assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true); + assert.equal(testGlobals.selectionAddCount() > 0, true); + + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + handlers.closeLookupWindow(); + ctx.state.yomitanPopupVisible = false; + testGlobals.setPopupVisible(false); + testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT); + await wait(0); + + assert.equal(ctx.state.keyboardDrivenModeEnabled, true); + assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true); + assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false); + assert.equal(testGlobals.selectionClearCount() > 0, true); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + + handlers.handleLookupWindowToggleRequested(); + await wait(0); + + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + handlers.handleLookupWindowToggleRequested(); + await wait(0); + + const closeCommands = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource', + ); + assert.deepEqual(closeCommands.slice(-2), [ + { type: 'setVisible', visible: false }, + { type: 'clearActiveTextSource' }, + ]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + ctx.state.yomitanPopupVisible = false; + testGlobals.setPopupVisible(true); + + handlers.handleLookupWindowToggleRequested(); + await wait(0); + + const closeCommands = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource', + ); + assert.deepEqual(closeCommands.slice(-2), [ + { type: 'setVisible', visible: false }, + { type: 'clearActiveTextSource' }, + ]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => { const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); @@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s } }); +test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(0); + handlers.syncKeyboardTokenSelection(); + + testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' }); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]); + + testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' }); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(0); + handlers.syncKeyboardTokenSelection(); + + assert.equal(handlers.moveSelectionForController(1), true); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]); + + assert.equal(handlers.moveSelectionForController(-1), true); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => { const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); @@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s } }); +test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(3); + ctx.state.keyboardSelectedWordIndex = 2; + handlers.syncKeyboardTokenSelection(); + + handlers.handleSubtitleContentUpdated(); + setWordCount(4); + handlers.syncKeyboardTokenSelection(); + + assert.equal(ctx.state.keyboardSelectedWordIndex, 0); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => { const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index bde2db8..b43d3f3 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -15,6 +15,8 @@ export function createKeyboardHandlers( handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; + handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; + handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; openSessionHelpModal: (opening: { bindingKey: 'KeyH' | 'KeyK'; @@ -23,6 +25,8 @@ export function createKeyboardHandlers( }) => void; appendClipboardVideoToQueue: () => void; getPlaybackPaused: () => Promise; + openControllerSelectModal: () => void; + openControllerDebugModal: () => void; }, ) { // Timeout for the modal chord capture window (e.g. Y followed by H/K). @@ -30,6 +34,7 @@ export function createKeyboardHandlers( const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingLookupRefreshAfterSubtitleSeek = false; + let resetSelectionToStartOnNextSubtitleSync = false; const CHORD_MAP = new Map< string, @@ -105,6 +110,39 @@ export function createKeyboardHandlers( ); } + function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) { + window.dispatchEvent( + new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { + detail: { + type: 'cycleAudioSource', + direction, + }, + }), + ); + } + + function dispatchYomitanPopupPlayCurrentAudio() { + window.dispatchEvent( + new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { + detail: { + type: 'playCurrentAudio', + }, + }), + ); + } + + function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) { + window.dispatchEvent( + new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { + detail: { + type: 'scrollBy', + deltaX, + deltaY, + }, + }), + ); + } + function dispatchYomitanFrontendScanSelectedText() { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { @@ -115,6 +153,16 @@ export function createKeyboardHandlers( ); } + function dispatchYomitanFrontendClearActiveTextSource() { + window.dispatchEvent( + new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { + detail: { + type: 'clearActiveTextSource', + }, + }), + ); + } + function isPrimaryModifierPressed(e: KeyboardEvent): boolean { return e.ctrlKey || e.metaKey; } @@ -129,23 +177,39 @@ export function createKeyboardHandlers( return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; } + function isControllerModalShortcut(e: KeyboardEvent): boolean { + return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC'; + } + function getSubtitleWordNodes(): HTMLElement[] { return Array.from( ctx.dom.subtitleRoot.querySelectorAll('.word[data-token-index]'), ); } - function syncKeyboardTokenSelection(): void { - const wordNodes = getSubtitleWordNodes(); + function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void { for (const wordNode of wordNodes) { wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS); } + } + + function clearNativeSubtitleSelection(): void { + window.getSelection()?.removeAllRanges(); + ctx.dom.subtitleRoot.classList.remove('has-selection'); + } + + function syncKeyboardTokenSelection(): void { + const wordNodes = getSubtitleWordNodes(); + clearKeyboardSelectedWordClasses(wordNodes); if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) { ctx.state.keyboardSelectedWordIndex = null; + ctx.state.keyboardSelectionVisible = false; if (!ctx.state.keyboardDrivenModeEnabled) { pendingSelectionAnchorAfterSubtitleSeek = null; pendingLookupRefreshAfterSubtitleSeek = false; + resetSelectionToStartOnNextSubtitleSync = false; + clearNativeSubtitleSelection(); } return; } @@ -153,7 +217,9 @@ export function createKeyboardHandlers( if (pendingSelectionAnchorAfterSubtitleSeek) { ctx.state.keyboardSelectedWordIndex = pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1; + ctx.state.keyboardSelectionVisible = true; pendingSelectionAnchorAfterSubtitleSeek = null; + resetSelectionToStartOnNextSubtitleSync = false; const shouldRefreshLookup = pendingLookupRefreshAfterSubtitleSeek && (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)); @@ -165,23 +231,32 @@ export function createKeyboardHandlers( } } + if (resetSelectionToStartOnNextSubtitleSync) { + ctx.state.keyboardSelectedWordIndex = 0; + ctx.state.keyboardSelectionVisible = true; + resetSelectionToStartOnNextSubtitleSync = false; + } + const selectedIndex = Math.min( Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), wordNodes.length - 1, ); ctx.state.keyboardSelectedWordIndex = selectedIndex; const selectedWordNode = wordNodes[selectedIndex]; - if (selectedWordNode) { + if (selectedWordNode && ctx.state.keyboardSelectionVisible) { selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS); } } function setKeyboardDrivenModeEnabled(enabled: boolean): void { ctx.state.keyboardDrivenModeEnabled = enabled; + ctx.state.keyboardSelectionVisible = enabled; if (!enabled) { ctx.state.keyboardSelectedWordIndex = null; pendingSelectionAnchorAfterSubtitleSeek = null; pendingLookupRefreshAfterSubtitleSeek = false; + resetSelectionToStartOnNextSubtitleSync = false; + clearNativeSubtitleSelection(); } syncKeyboardTokenSelection(); } @@ -213,6 +288,7 @@ export function createKeyboardHandlers( const nextIndex = currentIndex + delta; ctx.state.keyboardSelectedWordIndex = nextIndex; + ctx.state.keyboardSelectionVisible = true; syncKeyboardTokenSelection(); return 'moved'; } @@ -316,6 +392,7 @@ export function createKeyboardHandlers( const selectedWordNode = wordNodes[selectedIndex]; if (!selectedWordNode) return false; + ctx.state.keyboardSelectionVisible = true; syncKeyboardTokenSelection(); selectWordNodeText(selectedWordNode); @@ -347,19 +424,105 @@ export function createKeyboardHandlers( toggleKeyboardDrivenMode(); } + function handleSubtitleContentUpdated(): void { + if (!ctx.state.keyboardDrivenModeEnabled) { + return; + } + if (pendingSelectionAnchorAfterSubtitleSeek) { + return; + } + resetSelectionToStartOnNextSubtitleSync = true; + } + function handleLookupWindowToggleRequested(): void { - if (ctx.state.yomitanPopupVisible) { - dispatchYomitanPopupVisibility(false); - if (ctx.state.keyboardDrivenModeEnabled) { - queueMicrotask(() => { - restoreOverlayKeyboardFocus(); - }); - } + if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { + closeLookupWindow(); return; } triggerLookupForSelectedWord(); } + function closeLookupWindow(): boolean { + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return false; + } + + dispatchYomitanPopupVisibility(false); + dispatchYomitanFrontendClearActiveTextSource(); + clearNativeSubtitleSelection(); + if (ctx.state.keyboardDrivenModeEnabled) { + queueMicrotask(() => { + restoreOverlayKeyboardFocus(); + }); + } + return true; + } + + function moveSelectionForController(delta: -1 | 1): boolean { + if (!ctx.state.keyboardDrivenModeEnabled) { + return false; + } + + const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); + const result = moveKeyboardSelection(delta); + if (result === 'no-words') { + seekAdjacentSubtitleAndQueueSelection(delta, popupVisible); + return true; + } + + if (result === 'start-boundary' || result === 'end-boundary') { + seekAdjacentSubtitleAndQueueSelection(delta, popupVisible); + } else if (popupVisible && result === 'moved') { + triggerLookupForSelectedWord(); + } + + return true; + } + + function forwardPopupKeydownForController( + key: string, + code: string, + repeat: boolean = true, + ): boolean { + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return false; + } + dispatchYomitanPopupKeydown(key, code, [], repeat); + return true; + } + + function mineSelectedFromController(): boolean { + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return false; + } + dispatchYomitanPopupMineSelected(); + return true; + } + + function cyclePopupAudioSourceForController(direction: -1 | 1): boolean { + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return false; + } + dispatchYomitanPopupCycleAudioSource(direction); + return true; + } + + function playCurrentAudioForController(): boolean { + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return false; + } + dispatchYomitanPopupPlayCurrentAudio(); + return true; + } + + function scrollPopupByController(deltaX: number, deltaY: number): boolean { + if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + return false; + } + dispatchYomitanPopupScrollBy(deltaX, deltaY); + return true; + } + function restoreOverlayKeyboardFocus(): void { void window.electronAPI.focusMainWindow(); window.focus(); @@ -401,17 +564,17 @@ export function createKeyboardHandlers( const key = e.code; if (key === 'ArrowLeft') { const result = moveKeyboardSelection(-1); - if (result === 'start-boundary') { + if (result === 'start-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(-1, false); } - return result !== 'no-words'; + return true; } if (key === 'ArrowRight' || key === 'KeyL') { const result = moveKeyboardSelection(1); - if (result === 'end-boundary') { + if (result === 'end-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(1, false); } - return result !== 'no-words'; + return true; } return false; } @@ -428,7 +591,7 @@ export function createKeyboardHandlers( const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); if (key === 'ArrowLeft' || key === 'KeyH') { const result = moveKeyboardSelection(-1); - if (result === 'start-boundary') { + if (result === 'start-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(-1, popupVisible); } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); @@ -438,7 +601,7 @@ export function createKeyboardHandlers( if (key === 'ArrowRight' || key === 'KeyL') { const result = moveKeyboardSelection(1); - if (result === 'end-boundary') { + if (result === 'end-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(1, popupVisible); } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); @@ -540,7 +703,9 @@ export function createKeyboardHandlers( }); window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { + clearNativeSubtitleSelection(); if (!ctx.state.keyboardDrivenModeEnabled) { + syncKeyboardTokenSelection(); return; } restoreOverlayKeyboardFocus(); @@ -593,13 +758,6 @@ export function createKeyboardHandlers( return; } - if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { - if (handleYomitanPopupKeybind(e)) { - e.preventDefault(); - } - return; - } - if (ctx.state.runtimeOptionsModalOpen) { options.handleRuntimeOptionsKeydown(e); return; @@ -616,11 +774,29 @@ export function createKeyboardHandlers( options.handleJimakuKeydown(e); return; } + if (ctx.state.controllerSelectModalOpen) { + options.handleControllerSelectKeydown(e); + return; + } + if (ctx.state.controllerDebugModalOpen) { + options.handleControllerDebugKeydown(e); + return; + } if (ctx.state.sessionHelpModalOpen) { options.handleSessionHelpKeydown(e); return; } + if ( + (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && + !isControllerModalShortcut(e) + ) { + if (handleYomitanPopupKeybind(e)) { + e.preventDefault(); + } + return; + } + if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) { e.preventDefault(); return; @@ -671,6 +847,16 @@ export function createKeyboardHandlers( return; } + if (isControllerModalShortcut(e)) { + e.preventDefault(); + if (e.shiftKey) { + options.openControllerDebugModal(); + } else { + options.openControllerSelectModal(); + } + return; + } + const keyString = keyEventToString(e); const command = ctx.state.keybindingsMap.get(keyString); @@ -707,7 +893,15 @@ export function createKeyboardHandlers( setupMpvInputForwarding, updateKeybindings, syncKeyboardTokenSelection, + handleSubtitleContentUpdated, handleKeyboardModeToggleRequested, handleLookupWindowToggleRequested, + closeLookupWindow, + moveSelectionForController, + forwardPopupKeydownForController, + mineSelectedFromController, + cyclePopupAudioSourceForController, + playCurrentAudioForController, + scrollPopupByController, }; } diff --git a/src/renderer/index.html b/src/renderer/index.html index df4d105..e6a135c 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,6 +30,12 @@
+
+ +