2 Commits

Author SHA1 Message Date
68833c76c4 Release v0.6.1 and scope workflow write permissions
- Bump package version and changelog to v0.6.1
- Move `contents: write` permission to release job only
- Remove consumed changelog fragment files
2026-03-11 20:53:52 -07:00
4d7c80f2e4 Add overlay gamepad support for keyboard-only mode (#17) 2026-03-11 20:34:46 -07:00
51 changed files with 5676 additions and 53 deletions

View File

@@ -9,9 +9,6 @@ concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
quality-gate:
runs-on: ubuntu-latest
@@ -244,6 +241,8 @@ jobs:
release:
needs: [build-linux, build-macos, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4

View File

@@ -1,5 +1,20 @@
# Changelog
## v0.6.1 (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.
### Docs
- Install: Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
### Internal
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
- Release: Fixed the release workflow token permissions so tagged builds can download `oven-sh/setup-bun` and publish artifacts again.
## v0.5.6 (2026-03-10)
### Fixed

View File

@@ -0,0 +1,74 @@
---
id: TASK-159
title: Add overlay controller support for keyboard-only mode
status: Done
assignee:
- codex
created_date: '2026-03-11 00:30'
updated_date: '2026-03-11 04:05'
labels:
- enhancement
- renderer
- overlay
- input
dependencies:
- TASK-86
references:
- src/renderer/handlers/keyboard.ts
- src/renderer/renderer.ts
- src/renderer/state.ts
- src/renderer/index.html
- src/renderer/style.css
- src/preload.ts
- src/types.ts
- src/config/definitions/defaults-core.ts
- src/config/definitions/options-core.ts
- src/config/definitions/template-sections.ts
- config.example.jsonc
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add Chrome Gamepad API support to the visible overlay as a supplement to keyboard-only mode. By default SubMiner should bind to the first available controller, allow the user to pick and persist a preferred controller, expose a raw-input debug modal, and map controller actions onto the existing keyboard-only/Yomitan flow without breaking keyboard input. Also fix the current keyboard-only cleanup bug so the selected-token highlight clears when keyboard-only mode turns off or when the Yomitan popup closes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Controller input is ignored unless keyboard-only mode is enabled, except the controller binding for toggling keyboard-only mode itself.
- [x] #2 Default logical mappings work: smooth popup scroll, token selection, lookup toggle/close, mining, Yomitan audio navigation/play, and mpv play/pause.
- [x] #3 Controller config supports named logical bindings plus tuning knobs (preferred controller, deadzones, smooth-scroll speed/repeat), not raw axis/button maps.
- [x] #4 `Alt+C` opens a controller selection modal listing connected controllers; saving a choice persists the preferred controller for next launch.
- [x] #5 `Alt+Shift+C` opens a debug modal showing live raw controller axes/buttons as seen by SubMiner.
- [x] #6 Keyboard-only selection highlight clears immediately when keyboard-only mode is disabled or the Yomitan popup closes.
- [x] #7 Renderer/config regression tests cover controller gating, mappings, modal behavior, persisted selection, and highlight cleanup.
- [x] #8 Docs/config example describe the controller feature and new shortcuts.
<!-- AC:END -->
## Implementation Notes
- Added renderer-side gamepad polling and logical action mapping in `src/renderer/handlers/gamepad-controller.ts`.
- Added controller select/debug modals, persisted preferred-controller IPC, and top-level `controller` config defaults/schema/template output.
- Added a transient in-overlay controller status indicator when a controller is first detected.
- Tuned controller defaults and routing after live testing: d-pad fallback navigation, slower repeat timing, DOM-backed popup-open detection, and direct pixel scroll/audio-source popup bridge commands.
- Reused existing keyboard-only lookup/mining/navigation flows so controller input stays a supplement to keyboard-only mode instead of a parallel input path.
- Verified keyboard-only highlight cleanup on mode-off and popup-close paths with renderer tests.
## Verification
- `bun test src/config/config.test.ts src/config/definitions/domain-registry.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/handlers/gamepad-controller.test.ts src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts src/core/services/ipc.test.ts`
- `bun test src/main/runtime/composers/ipc-runtime-composer.test.ts`
- `bun run generate:config-example`
- `bun run typecheck`
- `bun run docs:test`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run docs:build`
- `bun run test:smoke:dist`

View File

@@ -1,4 +0,0 @@
type: docs
area: install
- Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.

View File

@@ -1,4 +0,0 @@
type: internal
area: config
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
# Overlay Controller Support Design
**Date:** 2026-03-11
**Backlog:** `TASK-159`
## Goal
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
## Scope
- Poll connected gamepads from the visible overlay renderer.
- Default to the first connected controller unless config specifies a preferred controller.
- Add logical controller bindings and tuning knobs to config.
- Add `Alt+C` controller selection modal.
- Add `Alt+Shift+C` controller debug modal.
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
Out of scope for this pass:
- Raw arbitrary axis/button index remapping in config.
- Controller support outside the visible overlay renderer.
- Haptics or vibration.
## Architecture
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
## Behavior
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
Default logical mappings:
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
- left stick horizontal: move token selection left/right
- right stick vertical: smooth Yomitan popup/window scroll
- right stick horizontal: jump horizontally inside Yomitan popup/window
- `A`: toggle lookup
- `B`: close lookup
- `Y`: toggle keyboard-only mode
- `X`: mine card
- `L1` / `R1`: previous / next Yomitan audio
- `R2`: activate current Yomitan audio button
- `L2`: toggle mpv play/pause
Selection-highlight cleanup:
- disabling keyboard-only mode clears the selected token class immediately
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
## Config
Add a top-level `controller` block in resolved config with:
- `enabled`
- `preferredGamepadId`
- `preferredGamepadLabel`
- `smoothScroll`
- `scrollPixelsPerSecond`
- `horizontalJumpPixels`
- `stickDeadzone`
- `triggerDeadzone`
- `repeatDelayMs`
- `repeatIntervalMs`
- `bindings` logical fields for the named actions/sticks
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
## UI
Controller selection modal:
- overlay-hosted modal in the visible renderer
- lists currently connected controllers
- highlights current active choice
- selecting one persists config and makes it the active controller immediately if connected
Controller debug modal:
- overlay-hosted modal
- shows selected controller and all connected controllers
- live raw axis array values
- live raw button values, pressed flags, and touched flags if available
## Testing
Test first:
- controller gating outside keyboard-only mode
- logical mapping to existing helpers
- continuous stick scroll and repeat behavior
- modal open shortcuts
- preferred-controller selection persistence
- highlight cleanup on keyboard-only disable and popup close
- config defaults/parse/template generation coverage
## Risks
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
Mitigation: match by exact preferred id first; fall back to first connected controller.
- Continuous stick input can spam actions.
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
- Popup DOM/audio controls may vary.
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.

View File

@@ -0,0 +1,245 @@
# Overlay Controller Support Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
---
### Task 1: Track work and lock the design
**Files:**
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
**Step 1: Record the approved scope**
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
**Step 2: Verify the written scope matches the approved design**
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
### Task 2: Add failing config tests and defaults
**Files:**
- Modify: `src/config/config.test.ts`
- Modify: `src/config/definitions/defaults-core.ts`
- Modify: `src/config/definitions/options-core.ts`
- Modify: `src/config/definitions/template-sections.ts`
- Modify: `src/types.ts`
- Modify: `config.example.jsonc`
**Step 1: Write the failing test**
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
**Step 2: Run test to verify it fails**
Run: `bun test src/config/config.test.ts`
Expected: FAIL because `controller` config is not defined yet.
**Step 3: Write minimal implementation**
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
**Step 4: Run test to verify it passes**
Run: `bun test src/config/config.test.ts`
Expected: PASS
### Task 3: Add failing keyboard-selection cleanup tests
**Files:**
- Modify: `src/renderer/handlers/keyboard.test.ts`
- Modify: `src/renderer/handlers/keyboard.ts`
- Modify: `src/renderer/state.ts`
**Step 1: Write the failing tests**
Add tests for:
- turning keyboard-only mode off clears `.keyboard-selected`
- closing the popup clears stale selection highlight when keyboard-only mode is off
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/handlers/keyboard.test.ts`
Expected: FAIL because selection cleanup is incomplete today.
**Step 3: Write minimal implementation**
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/handlers/keyboard.test.ts`
Expected: PASS
### Task 4: Add failing controller runtime tests
**Files:**
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
- Create: `src/renderer/handlers/gamepad-controller.ts`
- Modify: `src/renderer/context.ts`
- Modify: `src/renderer/state.ts`
- Modify: `src/renderer/renderer.ts`
**Step 1: Write the failing tests**
Cover:
- first connected controller is selected by default
- preferred controller wins when connected
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
- stick/button mappings invoke the expected logical helpers
- smooth scroll and repeat throttling behavior
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
Expected: FAIL because controller runtime does not exist.
**Step 3: Write minimal implementation**
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
Expected: PASS
### Task 5: Add failing controller modal tests
**Files:**
- Modify: `src/renderer/index.html`
- Modify: `src/renderer/style.css`
- Create: `src/renderer/modals/controller-select.ts`
- Create: `src/renderer/modals/controller-select.test.ts`
- Create: `src/renderer/modals/controller-debug.ts`
- Create: `src/renderer/modals/controller-debug.test.ts`
- Modify: `src/renderer/renderer.ts`
- Modify: `src/renderer/context.ts`
- Modify: `src/renderer/state.ts`
**Step 1: Write the failing tests**
Add tests for:
- `Alt+C` opens controller selection modal
- `Alt+Shift+C` opens controller debug modal
- selection modal renders connected controllers and persists the chosen device
- debug modal shows live axes/buttons state
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
Expected: FAIL because modals and shortcuts do not exist.
**Step 3: Write minimal implementation**
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
Expected: PASS
### Task 6: Persist controller preference through preload/main wiring
**Files:**
- Modify: `src/preload.ts`
- Modify: `src/types.ts`
- Modify: `src/shared/ipc/contracts.ts`
- Modify: `src/core/services/ipc.ts`
- Modify: `src/main.ts`
- Modify: related main/runtime tests as needed
**Step 1: Write the failing test**
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
**Step 2: Run test to verify it fails**
Run: `bun test src/core/services/ipc.test.ts`
Expected: FAIL because no controller preference IPC exists yet.
**Step 3: Write minimal implementation**
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
**Step 4: Run test to verify it passes**
Run: `bun test src/core/services/ipc.test.ts`
Expected: PASS
### Task 7: Update docs and config example
**Files:**
- Modify: `config.example.jsonc`
- Modify: `README.md`
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
**Step 1: Write the failing doc/config check if needed**
If config example generation is covered by tests, add/refresh the failing assertion first.
**Step 2: Implement the docs**
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
**Step 3: Run doc/config verification**
Run: `bun run test:config`
Expected: PASS
### Task 8: Run the handoff gate and update the backlog task
**Files:**
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
**Step 1: Run targeted verification**
Run:
- `bun test src/config/config.test.ts`
- `bun test src/renderer/handlers/keyboard.test.ts`
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
- `bun test src/renderer/modals/controller-select.test.ts`
- `bun test src/renderer/modals/controller-debug.test.ts`
- `bun test src/core/services/ipc.test.ts`
Expected: PASS
**Step 2: Run broader gate**
Run:
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
Expected: PASS, or document exact blockers/failures.
**Step 3: Update backlog notes**
Fill in implementation notes, verification commands, and final summary in `TASK-159`.

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.5.6",
"version": "0.6.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",

View File

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

View File

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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: [

View File

@@ -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 } => {

View File

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

View File

@@ -1,6 +1,8 @@
import electron from 'electron';
import type { IpcMainEvent } from 'electron';
import type {
ControllerPreferenceUpdate,
ResolvedControllerConfig,
RuntimeOptionId,
RuntimeOptionValue,
SubtitlePosition,
@@ -10,6 +12,7 @@ import type {
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
parseMpvCommand,
parseControllerPreferenceUpdate,
parseOptionalForwardingOptions,
parseOverlayHostedModal,
parseRuntimeOptionDirection,
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts,
getControllerConfig: options.getControllerConfig,
saveControllerPreference: options.saveControllerPreference,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
focusMainWindow: () => {
@@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.saveSubtitlePosition(parsedPosition);
});
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerPreferenceUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller preference payload');
}
await deps.saveControllerPreference(parsedUpdate);
});
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus();
});
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getConfiguredShortcuts();
});
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
return deps.getControllerConfig();
});
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
return deps.getSecondarySubMode();
});

View File

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

View File

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

View File

@@ -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 };

View File

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

View File

@@ -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: {

View File

@@ -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[];

View File

@@ -48,6 +48,8 @@ import type {
OverlayContentMeasurement,
ShortcutsConfig,
ConfigHotReloadPayload,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
notifyOverlayModalClosed: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
},
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
notifyOverlayModalOpened: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
},
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {

View File

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
let nextTimerId = 1;
const scheduled = new Map<number, () => void>();
const classList = createClassList(['hidden']);
const toast = {
textContent: '',
classList,
};
const indicator = createControllerStatusIndicator(
{ controllerStatusToast: toast } as never,
{
durationMs: 1500,
setTimeout: (callback: () => void) => {
const id = nextTimerId++;
scheduled.set(id, callback);
return id as never;
},
clearTimeout: (id) => {
scheduled.delete(id as never as number);
},
},
);
indicator.update({
connectedGamepads: [],
activeGamepadId: null,
});
assert.equal(classList.contains('hidden'), true);
assert.equal(toast.textContent, '');
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
assert.equal(classList.contains('hidden'), false);
assert.match(toast.textContent, /controller detected/i);
assert.match(toast.textContent, /pad-1/i);
assert.equal(scheduled.size, 1);
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
assert.equal(scheduled.size, 1);
const [hide] = scheduled.values();
hide?.();
assert.equal(classList.contains('hidden'), true);
assert.equal(toast.textContent, '');
});
test('controller status indicator announces newly detected controllers after startup', () => {
const toast = {
textContent: '',
classList: createClassList(['hidden']),
};
const indicator = createControllerStatusIndicator(
{ controllerStatusToast: toast } as never,
{
setTimeout: () => 1 as never,
clearTimeout: () => {},
},
);
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
toast.classList.add('hidden');
toast.textContent = '';
indicator.update({
connectedGamepads: [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
],
activeGamepadId: 'pad-1',
});
assert.equal(toast.classList.contains('hidden'), false);
assert.match(toast.textContent, /pad-2/i);
});

View File

@@ -0,0 +1,69 @@
import type { ControllerDeviceInfo } from '../types';
type ControllerSnapshot = {
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
};
type ControllerStatusIndicatorOptions = {
durationMs?: number;
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
};
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
if (!device) return 'Controller';
return device.id || `Gamepad ${device.index}`;
}
export function createControllerStatusIndicator(
dom: {
controllerStatusToast: {
textContent: string;
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
};
},
options: ControllerStatusIndicatorOptions = {},
) {
const durationMs = options.durationMs ?? 2200;
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
const cancelTimeout =
options.clearTimeout ??
((timer: ReturnType<typeof setTimeout> | number) =>
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
let previousConnectedIds = new Set<string>();
function show(message: string): void {
if (hideTimeout !== null) {
cancelTimeout(hideTimeout);
hideTimeout = null;
}
dom.controllerStatusToast.textContent = message;
dom.controllerStatusToast.classList.remove('hidden');
hideTimeout = scheduleTimeout(() => {
dom.controllerStatusToast.classList.add('hidden');
dom.controllerStatusToast.textContent = '';
hideTimeout = null;
}, durationMs);
}
function update(snapshot: ControllerSnapshot): void {
const newDevices = snapshot.connectedGamepads.filter(
(device) => !previousConnectedIds.has(device.id),
);
if (newDevices.length > 0) {
const activeDevice = snapshot.connectedGamepads.find(
(device) => device.id === snapshot.activeGamepadId,
);
const announcedDevice =
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
}
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
}
return { update };
}

View File

@@ -0,0 +1,645 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ResolvedControllerConfig } from '../../types';
import { createGamepadController } from './gamepad-controller.js';
type TestGamepad = {
id: string;
index: number;
connected: boolean;
mapping: string;
axes: number[];
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
};
function createGamepad(
id: string,
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
): TestGamepad {
return {
id,
index: options.index ?? 0,
connected: true,
mapping: 'standard',
axes: options.axes ?? [0, 0, 0, 0],
buttons:
options.buttons ??
Array.from({ length: 16 }, () => ({
value: 0,
pressed: false,
touched: false,
})),
};
}
function createControllerConfig(
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
bindings?: Partial<ResolvedControllerConfig['bindings']>;
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
} = {},
): ResolvedControllerConfig {
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
overrides;
return {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
...(buttonIndexOverrides ?? {}),
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
...(bindingOverrides ?? {}),
},
...restOverrides,
};
}
test('gamepad controller selects the first connected controller by default', () => {
const updates: string[] = [];
const controller = createGamepadController({
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: (state) => {
updates.push(state.activeGamepadId ?? 'none');
},
});
controller.poll(0);
assert.equal(controller.getActiveGamepadId(), 'pad-2');
assert.deepEqual(updates.at(-1), 'pad-2');
});
test('gamepad controller prefers saved controller id when connected', () => {
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.equal(controller.getActiveGamepadId(), 'pad-2');
});
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig({ enabled: false }),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, []);
});
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
const calls: string[] = [];
const selectionCalls: number[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
let axes = [0.9, 0, 0, 0];
let keyboardModeEnabled = true;
let interactionBlocked = true;
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => keyboardModeEnabled,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => interactionBlocked,
toggleKeyboardMode: () => {},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: (delta) => selectionCalls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
interactionBlocked = false;
controller.poll(100);
assert.deepEqual(calls, []);
assert.deepEqual(selectionCalls, []);
buttons[0] = { value: 0, pressed: false, touched: false };
axes = [0, 0, 0, 0];
controller.poll(200);
buttons[0] = { value: 1, pressed: true, touched: true };
axes = [0.9, 0, 0, 0];
controller.poll(300);
assert.deepEqual(calls, ['toggle-lookup']);
assert.deepEqual(selectionCalls, [1]);
});
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
const calls: number[] = [];
let axes = [0.9, 0, 0, 0];
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { axes })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: (delta) => calls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
controller.poll(100);
controller.poll(260);
assert.deepEqual(calls, [1]);
controller.poll(340);
assert.deepEqual(calls, [1, 1]);
axes = [0, 0, 0, 0];
controller.poll(360);
axes = [-0.9, 0, 0, 0];
controller.poll(380);
assert.deepEqual(calls, [1, 1, -1]);
});
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
const calls: string[] = [];
const scrollCalls: number[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[8] = { value: 1, pressed: true, touched: true };
buttons[4] = { value: 1, pressed: true, touched: true };
buttons[5] = { value: 1, pressed: true, touched: true };
buttons[6] = { value: 0.8, pressed: true, touched: true };
buttons[7] = { value: 0.9, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () =>
[
createGamepad('pad-1', {
axes: [0, -0.75, 0.1, 0, 0.8],
buttons,
}),
],
getConfig: () =>
createControllerConfig({
bindings: {
playCurrentAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
previousAudio: 'none',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => calls.push('quit-mpv'),
previousAudio: () => calls.push('prev-audio'),
nextAudio: () => calls.push('next-audio'),
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: (delta) => scrollCalls.push(delta),
jumpPopup: (delta) => calls.push(`jump:${delta}`),
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.equal(calls.includes('next-audio'), true);
assert.equal(calls.includes('play-audio'), true);
assert.equal(calls.includes('prev-audio'), false);
assert.equal(calls.includes('toggle-mpv-pause'), true);
assert.equal(calls.includes('quit-mpv'), true);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
assert.equal(calls.includes('jump:160'), true);
});
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => calls.push('quit-mpv'),
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['quit-mpv']);
});
test('gamepad controller honors configured raw button index overrides', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[11] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
buttonIndices: {
select: 11,
},
bindings: { quitMpv: 'select' },
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => calls.push('quit-mpv'),
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['quit-mpv']);
});
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
const calls: string[] = [];
let axes = [0, 0, 0.85, 0, 0];
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { axes })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: (delta) => calls.push(`jump:${delta}`),
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.deepEqual(calls, []);
axes = [0, 0, 0.85, 0, -0.85];
controller.poll(200);
assert.deepEqual(calls, ['jump:-160']);
});
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
const selectionCalls: number[] = [];
const scrollCalls: number[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[15] = { value: 1, pressed: false, touched: true };
buttons[12] = { value: 1, pressed: false, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: (delta) => selectionCalls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: (delta) => scrollCalls.push(delta),
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.deepEqual(selectionCalls, [1]);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
});
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
const selectionCalls: number[] = [];
const scrollCalls: number[] = [];
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: (delta) => selectionCalls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: (delta) => scrollCalls.push(delta),
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.deepEqual(selectionCalls, [1]);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
});
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 0.7, pressed: false, touched: true };
buttons[7] = { value: 0.8, pressed: false, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
triggerInputMode: 'analog',
triggerDeadzone: 0.6,
bindings: {
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
});
test('gamepad controller trigger digital mode uses pressed state only', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 0.9, pressed: true, touched: true };
buttons[7] = { value: 0.9, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
triggerInputMode: 'digital',
triggerDeadzone: 1,
bindings: {
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
});
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[9] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
bindings: {
toggleMpvPause: 'leftStickPress',
playCurrentAudio: 'none',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-mpv-pause']);
});

View File

@@ -0,0 +1,571 @@
import type {
ControllerAxisBinding,
ControllerButtonBinding,
ControllerDeviceInfo,
ControllerRuntimeSnapshot,
ControllerTriggerInputMode,
ResolvedControllerConfig,
} from '../../types';
type ControllerButtonState = {
value: number;
pressed?: boolean;
touched?: boolean;
};
type GamepadLike = {
id: string;
index: number;
connected: boolean;
mapping: string;
axes: readonly number[];
buttons: readonly ControllerButtonState[];
};
type GamepadControllerOptions = {
getGamepads: () => Array<GamepadLike | null>;
getConfig: () => ResolvedControllerConfig;
getKeyboardModeEnabled: () => boolean;
getLookupWindowOpen: () => boolean;
getInteractionBlocked: () => boolean;
toggleKeyboardMode: () => void;
toggleLookup: () => void;
closeLookup: () => void;
moveSelection: (delta: -1 | 1) => void;
mineCard: () => void;
quitMpv: () => void;
previousAudio: () => void;
nextAudio: () => void;
playCurrentAudio: () => void;
toggleMpvPause: () => void;
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
onState: (state: ControllerRuntimeSnapshot) => void;
};
type HoldState = {
repeatStarted: boolean;
direction: -1 | 1 | null;
lastFireAt: number;
initialFired: boolean;
};
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, number> = {
select: 8,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
};
const AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const DPAD_BUTTON_INDEX = {
up: 12,
down: 13,
left: 14,
right: 15,
} as const;
const DPAD_AXIS_INDEX = {
horizontal: 6,
vertical: 7,
} as const;
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
return binding === 'leftTrigger' || binding === 'rightTrigger';
}
function resolveButtonIndex(
config: ResolvedControllerConfig,
binding: ControllerButtonBinding,
): number {
if (binding === 'none') {
return -1;
}
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
}
function normalizeButtonState(
gamepad: GamepadLike,
config: ResolvedControllerConfig,
binding: ControllerButtonBinding,
triggerInputMode: ControllerTriggerInputMode,
triggerDeadzone: number,
): boolean {
if (binding === 'none') {
return false;
}
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
if (isTriggerBinding(binding)) {
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
}
return normalizeRawButtonState(button, triggerDeadzone);
}
function normalizeRawButtonState(
button: ControllerButtonState | undefined,
triggerDeadzone: number,
): boolean {
if (!button) return false;
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function normalizeTriggerState(
button: ControllerButtonState | undefined,
mode: ControllerTriggerInputMode,
triggerDeadzone: number,
): boolean {
if (!button) return false;
if (mode === 'digital') {
return Boolean(button.pressed);
}
if (mode === 'analog') {
return button.value >= triggerDeadzone;
}
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
}
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
const value = gamepad.axes[axisIndex];
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
function resolveDpadValue(
gamepad: GamepadLike,
negativeIndex: number,
positiveIndex: number,
triggerDeadzone: number,
): number {
const negative = gamepad.buttons[negativeIndex];
const positive = gamepad.buttons[positiveIndex];
return (
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
);
}
function resolveDpadAxisValue(
gamepad: GamepadLike,
axisIndex: number,
): number {
const value = resolveGamepadAxis(gamepad, axisIndex);
if (Math.abs(value) < 0.5) {
return 0;
}
return Math.sign(value);
}
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
if (axisValue !== 0) {
return axisValue;
}
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
}
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
if (axisValue !== 0) {
return axisValue;
}
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
}
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
return gamepads
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
.sort((left, right) => left.index - right.index);
}
function createHoldState(): HoldState {
return {
repeatStarted: false,
direction: null,
lastFireAt: 0,
initialFired: false,
};
}
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
if (!state.initialFired) {
state.initialFired = true;
state.lastFireAt = now;
return true;
}
const elapsed = now - state.lastFireAt;
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
if (elapsed < threshold) {
return false;
}
state.repeatStarted = true;
state.lastFireAt = now;
return true;
}
function resetHeldAction(state: HoldState): void {
state.repeatStarted = false;
state.direction = null;
state.lastFireAt = 0;
state.initialFired = false;
}
function syncHeldActionBlocked(
state: HoldState,
value: number,
now: number,
activationThreshold: number,
): void {
if (Math.abs(value) < activationThreshold) {
resetHeldAction(state);
return;
}
const direction = value > 0 ? 1 : -1;
state.repeatStarted = false;
state.direction = direction;
state.lastFireAt = now;
state.initialFired = true;
}
export function createGamepadController(options: GamepadControllerOptions) {
let previousButtons = new Map<ControllerButtonBinding, boolean>();
let selectionHold = createHoldState();
let jumpHold = createHoldState();
let activeGamepadId: string | null = null;
let lastPollAt: number | null = null;
function getConnectedGamepads(): GamepadLike[] {
return resolveConnectedGamepads(options.getGamepads());
}
function resolveActiveGamepad(
gamepads: GamepadLike[],
config: ResolvedControllerConfig,
): GamepadLike | null {
if (gamepads.length === 0) return null;
if (config.preferredGamepadId.trim().length > 0) {
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
if (preferred) {
return preferred;
}
}
return gamepads[0] ?? null;
}
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
activeGamepadId = activeGamepad?.id ?? null;
options.onState({
connectedGamepads: gamepads.map((gamepad) => ({
id: gamepad.id,
index: gamepad.index,
mapping: gamepad.mapping,
connected: gamepad.connected,
})) satisfies ControllerDeviceInfo[],
activeGamepadId,
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
rawButtons: activeGamepad?.buttons
? activeGamepad.buttons.map((button) => ({
value: button.value,
pressed: Boolean(button.pressed),
touched: button.touched,
}))
: [],
});
}
function handleButtonEdge(
binding: ControllerButtonBinding,
isPressed: boolean,
action: () => void,
): void {
if (binding === 'none') {
return;
}
const wasPressed = previousButtons.get(binding) ?? false;
previousButtons.set(binding, isPressed);
if (!wasPressed && isPressed) {
action();
}
}
function handleSelectionAxis(
value: number,
now: number,
config: ResolvedControllerConfig,
): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
resetHeldAction(selectionHold);
return;
}
const direction = value > 0 ? 1 : -1;
if (selectionHold.direction !== direction) {
resetHeldAction(selectionHold);
selectionHold.direction = direction;
}
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
options.moveSelection(direction);
}
}
function handleJumpAxis(
value: number,
now: number,
config: ResolvedControllerConfig,
): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
resetHeldAction(jumpHold);
return;
}
const direction = value > 0 ? 1 : -1;
if (jumpHold.direction !== direction) {
resetHeldAction(jumpHold);
jumpHold.direction = direction;
}
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
options.jumpPopup(direction * config.horizontalJumpPixels);
}
}
function syncBlockedInteractionState(
activeGamepad: GamepadLike,
config: ResolvedControllerConfig,
now: number,
): void {
const buttonBindings = new Set<ControllerButtonBinding>([
config.bindings.toggleKeyboardOnlyMode,
config.bindings.toggleLookup,
config.bindings.closeLookup,
config.bindings.mineCard,
config.bindings.quitMpv,
config.bindings.previousAudio,
config.bindings.nextAudio,
config.bindings.playCurrentAudio,
config.bindings.toggleMpvPause,
]);
for (const binding of buttonBindings) {
if (binding === 'none') continue;
previousButtons.set(
binding,
normalizeButtonState(
activeGamepad,
config,
binding,
config.triggerInputMode,
config.triggerDeadzone,
),
);
}
const selectionValue = (() => {
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
return axisValue;
}
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
})();
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
if (options.getLookupWindowOpen()) {
syncHeldActionBlocked(
jumpHold,
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
now,
Math.max(config.stickDeadzone, 0.55),
);
} else {
resetHeldAction(jumpHold);
}
}
function poll(now: number): void {
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
lastPollAt = now;
const config = options.getConfig();
const connectedGamepads = getConnectedGamepads();
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
publishState(connectedGamepads, activeGamepad);
if (!activeGamepad) {
previousButtons = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
lastPollAt = null;
return;
}
const interactionAllowed =
config.enabled &&
options.getKeyboardModeEnabled() &&
!options.getInteractionBlocked();
if (config.enabled) {
handleButtonEdge(
config.bindings.toggleKeyboardOnlyMode,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleKeyboardOnlyMode,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleKeyboardMode,
);
}
if (!interactionAllowed) {
syncBlockedInteractionState(activeGamepad, config, now);
return;
}
handleButtonEdge(
config.bindings.toggleLookup,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleLookup,
);
handleButtonEdge(
config.bindings.closeLookup,
normalizeButtonState(
activeGamepad,
config,
config.bindings.closeLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
options.closeLookup,
);
handleButtonEdge(
config.bindings.mineCard,
normalizeButtonState(
activeGamepad,
config,
config.bindings.mineCard,
config.triggerInputMode,
config.triggerDeadzone,
),
options.mineCard,
);
handleButtonEdge(
config.bindings.quitMpv,
normalizeButtonState(
activeGamepad,
config,
config.bindings.quitMpv,
config.triggerInputMode,
config.triggerDeadzone,
),
options.quitMpv,
);
if (options.getLookupWindowOpen()) {
handleButtonEdge(
config.bindings.previousAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.previousAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.previousAudio,
);
handleButtonEdge(
config.bindings.nextAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.nextAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.nextAudio,
);
handleButtonEdge(
config.bindings.playCurrentAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.playCurrentAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.playCurrentAudio,
);
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
if (elapsedMs > 0) {
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
if (dpadVertical !== 0) {
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
}
handleJumpAxis(
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
now,
config,
);
} else {
resetHeldAction(jumpHold);
}
handleButtonEdge(
config.bindings.toggleMpvPause,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleMpvPause,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleMpvPause,
);
handleSelectionAxis(
(() => {
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
return axisValue;
}
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
})(),
now,
config,
);
}
return {
poll,
getActiveGamepadId: (): string | null => activeGamepadId,
};
}

View File

@@ -3,7 +3,10 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
import {
YOMITAN_POPUP_COMMAND_EVENT,
YOMITAN_POPUP_HIDDEN_EVENT,
} from '../yomitan-popup.js';
type CommandEventDetail = {
type?: string;
@@ -11,6 +14,9 @@ type CommandEventDetail = {
key?: string;
code?: string;
repeat?: boolean;
direction?: number;
deltaX?: number;
deltaY?: number;
};
function createClassList() {
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = [];
let playbackPausedResponse: boolean | null = false;
let selectionClearCount = 0;
let selectionAddCount = 0;
let popupVisible = false;
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
};
const selection = {
removeAllRanges: () => {},
addRange: () => {},
removeAllRanges: () => {
selectionClearCount += 1;
},
addRange: () => {
selectionAddCount += 1;
},
};
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: () => {},
addEventListener: (type: string, listener: (event: unknown) => void) => {
const listeners = windowListeners.get(type) ?? [];
listeners.push(listener);
windowListeners.set(type, listeners);
},
dispatchEvent: (event: Event) => {
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
commandEvents.push(detail ?? {});
}
const listeners = windowListeners.get(event.type) ?? [];
for (const listener of listeners) {
listener(event);
}
return true;
},
getComputedStyle: () => ({
@@ -192,6 +213,13 @@ function installKeyboardTestGlobals() {
}
}
function dispatchWindowEvent(type: string): void {
const listeners = windowListeners.get(type) ?? [];
for (const listener of listeners) {
listener(new Event(type));
}
}
function restore() {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
windowFocusCalls: () => windowFocusCalls,
dispatchKeydown,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
popupVisible = value;
},
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
setPlaybackPausedResponse: (value: boolean | null) => {
playbackPausedResponse = value;
},
selectionClearCount: () => selectionClearCount,
selectionAddCount: () => selectionAddCount,
restore,
};
}
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList();
let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0;
const createWordNode = (left: number) => ({
classList: createClassList(),
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1;
return true;
},
handleControllerDebugKeydown: () => false,
handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {},
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectOpenCount += 1;
},
openControllerDebugModal: () => {
controllerDebugOpenCount += 1;
},
});
return {
ctx,
handlers,
testGlobals,
controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
@@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
}
});
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
assert.equal(handlers.playCurrentAudioForController(), true);
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
assert.equal(handlers.scrollPopupByController(48, -24), true);
assert.deepEqual(
testGlobals.commandEvents.slice(-3),
[
{ type: 'playCurrentAudio' },
{ type: 'cycleAudioSource', direction: 1 },
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
],
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({
key: 'C',
code: 'KeyC',
altKey: true,
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.dispatchKeydown({
key: 'C',
code: 'KeyC',
altKey: true,
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.controllerSelectModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(controllerSelectKeydownCount(), 1);
assert.equal(
testGlobals.commandEvents.some(
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
),
false,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
}
});
test('keyboard mode: turning mode off clears selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
handlers.handleLookupWindowToggleRequested();
await wait(0);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
assert.equal(testGlobals.selectionAddCount() > 0, true);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.closeLookupWindow();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
await wait(0);
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
assert.equal(testGlobals.selectionClearCount() > 0, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
handlers.handleLookupWindowToggleRequested();
await wait(0);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
const closeCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
);
assert.deepEqual(closeCommands.slice(-2), [
{ type: 'setVisible', visible: false },
{ type: 'clearActiveTextSource' },
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
const closeCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
);
assert.deepEqual(closeCommands.slice(-2), [
{ type: 'setVisible', visible: false },
{ type: 'clearActiveTextSource' },
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
@@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
}
});
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(0);
handlers.syncKeyboardTokenSelection();
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(0);
handlers.syncKeyboardTokenSelection();
assert.equal(handlers.moveSelectionForController(1), true);
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
assert.equal(handlers.moveSelectionForController(-1), true);
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
@@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
}
});
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(3);
ctx.state.keyboardSelectedWordIndex = 2;
handlers.syncKeyboardTokenSelection();
handlers.handleSubtitleContentUpdated();
setWordCount(4);
handlers.syncKeyboardTokenSelection();
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();

View File

@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK';
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
}) => void;
appendClipboardVideoToQueue: () => void;
getPlaybackPaused: () => Promise<boolean | null>;
openControllerSelectModal: () => void;
openControllerDebugModal: () => void;
},
) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
@@ -30,6 +34,7 @@ export function createKeyboardHandlers(
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false;
const CHORD_MAP = new Map<
string,
@@ -105,6 +110,39 @@ export function createKeyboardHandlers(
);
}
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'cycleAudioSource',
direction,
},
}),
);
}
function dispatchYomitanPopupPlayCurrentAudio() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'playCurrentAudio',
},
}),
);
}
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'scrollBy',
deltaX,
deltaY,
},
}),
);
}
function dispatchYomitanFrontendScanSelectedText() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
@@ -115,6 +153,16 @@ export function createKeyboardHandlers(
);
}
function dispatchYomitanFrontendClearActiveTextSource() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'clearActiveTextSource',
},
}),
);
}
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey;
}
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
}
function isControllerModalShortcut(e: KeyboardEvent): boolean {
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
}
function getSubtitleWordNodes(): HTMLElement[] {
return Array.from(
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
);
}
function syncKeyboardTokenSelection(): void {
const wordNodes = getSubtitleWordNodes();
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
for (const wordNode of wordNodes) {
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
}
}
function clearNativeSubtitleSelection(): void {
window.getSelection()?.removeAllRanges();
ctx.dom.subtitleRoot.classList.remove('has-selection');
}
function syncKeyboardTokenSelection(): void {
const wordNodes = getSubtitleWordNodes();
clearKeyboardSelectedWordClasses(wordNodes);
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
ctx.state.keyboardSelectedWordIndex = null;
ctx.state.keyboardSelectionVisible = false;
if (!ctx.state.keyboardDrivenModeEnabled) {
pendingSelectionAnchorAfterSubtitleSeek = null;
pendingLookupRefreshAfterSubtitleSeek = false;
resetSelectionToStartOnNextSubtitleSync = false;
clearNativeSubtitleSelection();
}
return;
}
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
if (pendingSelectionAnchorAfterSubtitleSeek) {
ctx.state.keyboardSelectedWordIndex =
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
ctx.state.keyboardSelectionVisible = true;
pendingSelectionAnchorAfterSubtitleSeek = null;
resetSelectionToStartOnNextSubtitleSync = false;
const shouldRefreshLookup =
pendingLookupRefreshAfterSubtitleSeek &&
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
@@ -165,23 +231,32 @@ export function createKeyboardHandlers(
}
}
if (resetSelectionToStartOnNextSubtitleSync) {
ctx.state.keyboardSelectedWordIndex = 0;
ctx.state.keyboardSelectionVisible = true;
resetSelectionToStartOnNextSubtitleSync = false;
}
const selectedIndex = Math.min(
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
wordNodes.length - 1,
);
ctx.state.keyboardSelectedWordIndex = selectedIndex;
const selectedWordNode = wordNodes[selectedIndex];
if (selectedWordNode) {
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
}
}
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
ctx.state.keyboardDrivenModeEnabled = enabled;
ctx.state.keyboardSelectionVisible = enabled;
if (!enabled) {
ctx.state.keyboardSelectedWordIndex = null;
pendingSelectionAnchorAfterSubtitleSeek = null;
pendingLookupRefreshAfterSubtitleSeek = false;
resetSelectionToStartOnNextSubtitleSync = false;
clearNativeSubtitleSelection();
}
syncKeyboardTokenSelection();
}
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
const nextIndex = currentIndex + delta;
ctx.state.keyboardSelectedWordIndex = nextIndex;
ctx.state.keyboardSelectionVisible = true;
syncKeyboardTokenSelection();
return 'moved';
}
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
const selectedWordNode = wordNodes[selectedIndex];
if (!selectedWordNode) return false;
ctx.state.keyboardSelectionVisible = true;
syncKeyboardTokenSelection();
selectWordNodeText(selectedWordNode);
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
toggleKeyboardDrivenMode();
}
function handleSubtitleContentUpdated(): void {
if (!ctx.state.keyboardDrivenModeEnabled) {
return;
}
if (pendingSelectionAnchorAfterSubtitleSeek) {
return;
}
resetSelectionToStartOnNextSubtitleSync = true;
}
function handleLookupWindowToggleRequested(): void {
if (ctx.state.yomitanPopupVisible) {
dispatchYomitanPopupVisibility(false);
if (ctx.state.keyboardDrivenModeEnabled) {
queueMicrotask(() => {
restoreOverlayKeyboardFocus();
});
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
closeLookupWindow();
return;
}
triggerLookupForSelectedWord();
}
function closeLookupWindow(): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupVisibility(false);
dispatchYomitanFrontendClearActiveTextSource();
clearNativeSubtitleSelection();
if (ctx.state.keyboardDrivenModeEnabled) {
queueMicrotask(() => {
restoreOverlayKeyboardFocus();
});
}
return true;
}
function moveSelectionForController(delta: -1 | 1): boolean {
if (!ctx.state.keyboardDrivenModeEnabled) {
return false;
}
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
const result = moveKeyboardSelection(delta);
if (result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
return true;
}
if (result === 'start-boundary' || result === 'end-boundary') {
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
} else if (popupVisible && result === 'moved') {
triggerLookupForSelectedWord();
}
return true;
}
function forwardPopupKeydownForController(
key: string,
code: string,
repeat: boolean = true,
): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupKeydown(key, code, [], repeat);
return true;
}
function mineSelectedFromController(): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupMineSelected();
return true;
}
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupCycleAudioSource(direction);
return true;
}
function playCurrentAudioForController(): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupPlayCurrentAudio();
return true;
}
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupScrollBy(deltaX, deltaY);
return true;
}
function restoreOverlayKeyboardFocus(): void {
void window.electronAPI.focusMainWindow();
window.focus();
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
const key = e.code;
if (key === 'ArrowLeft') {
const result = moveKeyboardSelection(-1);
if (result === 'start-boundary') {
if (result === 'start-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(-1, false);
}
return result !== 'no-words';
return true;
}
if (key === 'ArrowRight' || key === 'KeyL') {
const result = moveKeyboardSelection(1);
if (result === 'end-boundary') {
if (result === 'end-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(1, false);
}
return result !== 'no-words';
return true;
}
return false;
}
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
if (key === 'ArrowLeft' || key === 'KeyH') {
const result = moveKeyboardSelection(-1);
if (result === 'start-boundary') {
if (result === 'start-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
} else if (popupVisible && result === 'moved') {
triggerLookupForSelectedWord();
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
if (key === 'ArrowRight' || key === 'KeyL') {
const result = moveKeyboardSelection(1);
if (result === 'end-boundary') {
if (result === 'end-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
} else if (popupVisible && result === 'moved') {
triggerLookupForSelectedWord();
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
});
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
clearNativeSubtitleSelection();
if (!ctx.state.keyboardDrivenModeEnabled) {
syncKeyboardTokenSelection();
return;
}
restoreOverlayKeyboardFocus();
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
}
return;
}
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
return;
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e);
return;
}
if (ctx.state.controllerSelectModalOpen) {
options.handleControllerSelectKeydown(e);
return;
}
if (ctx.state.controllerDebugModalOpen) {
options.handleControllerDebugKeydown(e);
return;
}
if (ctx.state.sessionHelpModalOpen) {
options.handleSessionHelpKeydown(e);
return;
}
if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e)
) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
}
return;
}
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
e.preventDefault();
return;
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
return;
}
if (isControllerModalShortcut(e)) {
e.preventDefault();
if (e.shiftKey) {
options.openControllerDebugModal();
} else {
options.openControllerSelectModal();
}
return;
}
const keyString = keyEventToString(e);
const command = ctx.state.keybindingsMap.get(keyString);
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
setupMpvInputForwarding,
updateKeybindings,
syncKeyboardTokenSelection,
handleSubtitleContentUpdated,
handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested,
closeLookupWindow,
moveSelectionForController,
forwardPopupKeydownForController,
mineSelectedFromController,
cyclePopupAudioSourceForController,
playCurrentAudioForController,
scrollPopupByController,
};
}

View File

@@ -30,6 +30,12 @@
<body>
<!-- Programmatic focus fallback target for Electron/window focus management. -->
<div id="overlay" tabindex="-1">
<div
id="controllerStatusToast"
class="controller-status-toast hidden"
role="status"
aria-live="polite"
></div>
<div
id="overlayErrorToast"
class="overlay-error-toast hidden"
@@ -192,6 +198,62 @@
</div>
</div>
</div>
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content">
<div class="modal-header">
<div class="modal-title">Controller Selection</div>
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="controllerSelectHint" class="runtime-options-hint">
Arrow keys: select controller · Enter: save · Esc: close
</div>
<ul id="controllerSelectList" class="runtime-options-list"></ul>
<div id="controllerSelectStatus" class="runtime-options-status"></div>
<div class="subsync-footer">
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
Save Controller
</button>
</div>
</div>
</div>
</div>
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content controller-debug-content">
<div
id="controllerDebugToast"
class="controller-debug-toast hidden"
aria-live="polite"
></div>
<div class="modal-header">
<div class="modal-title">Controller Debug</div>
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="controllerDebugStatus" class="runtime-options-status"></div>
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
<div class="controller-debug-grid">
<div>
<div class="jimaku-section-title">Axes</div>
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
</div>
<div>
<div class="jimaku-section-title">Buttons</div>
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
</div>
<div class="controller-debug-span">
<div class="controller-debug-section-header">
<div class="jimaku-section-title">Config</div>
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
Copy
</button>
</div>
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
</div>
</div>
</div>
</div>
</div>
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
<div class="modal-content session-help-content">
<div class="modal-header">

View File

@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerDebugModal } from './controller-debug.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
const globals = globalThis as typeof globalThis & { window?: unknown };
const previousWindow = globals.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
notifyOverlayModalClosed: () => {},
},
},
});
try {
const state = createRendererState();
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
state.controllerRawAxes = [0.5, -0.25];
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
state.controllerConfig = {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
controllerDebugModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerDebugClose: { addEventListener: () => {} },
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
controllerDebugStatus: { textContent: '', classList: createClassList() },
controllerDebugSummary: { textContent: '' },
controllerDebugAxes: { textContent: '' },
controllerDebugButtons: { textContent: '' },
controllerDebugButtonIndices: { textContent: '' },
},
state,
};
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerDebugModal();
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('controller debug modal copies buttonIndices config to clipboard', async () => {
const globals = globalThis as typeof globalThis & {
window?: unknown;
navigator?: unknown;
};
const previousWindow = globals.window;
const previousNavigator = globals.navigator;
const copied: string[] = [];
const handlers: { copy: null | (() => void) } = { copy: null };
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
clipboard: {
writeText: async (text: string) => {
copied.push(text);
},
},
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
controllerDebugModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerDebugClose: { addEventListener: () => {} },
controllerDebugCopy: {
addEventListener: (_event: string, handler: () => void) => {
handlers.copy = handler;
},
},
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
controllerDebugStatus: { textContent: '', classList: createClassList() },
controllerDebugSummary: { textContent: '' },
controllerDebugAxes: { textContent: '' },
controllerDebugButtons: { textContent: '' },
controllerDebugButtonIndices: { textContent: '' },
},
state,
};
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.wireDomEvents();
modal.openControllerDebugModal();
if (handlers.copy) {
handlers.copy();
}
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});

View File

@@ -0,0 +1,192 @@
import type { ModalStateReader, RendererContext } from '../context';
function formatAxes(values: number[]): string {
if (values.length === 0) return 'No controller axes available.';
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
}
function formatButtons(
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
): string {
if (values.length === 0) return 'No controller buttons available.';
return values
.map(
(button, index) =>
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
)
.join('\n');
}
function formatButtonIndices(
value:
| {
select: number;
buttonSouth: number;
buttonEast: number;
buttonNorth: number;
buttonWest: number;
leftShoulder: number;
rightShoulder: number;
leftStickPress: number;
rightStickPress: number;
leftTrigger: number;
rightTrigger: number;
}
| null,
): string {
if (!value) {
return 'No controller config loaded.';
}
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
}
async function writeTextToClipboard(text: string): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API unavailable.');
}
await navigator.clipboard.writeText(text);
}
export function createControllerDebugModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function setStatus(message: string, isError: boolean = false): void {
ctx.dom.controllerDebugStatus.textContent = message;
if (isError) {
ctx.dom.controllerDebugStatus.classList.add('error');
} else {
ctx.dom.controllerDebugStatus.classList.remove('error');
}
}
function clearToastTimer(): void {
if (toastTimer === null) return;
clearTimeout(toastTimer);
toastTimer = null;
}
function hideToast(): void {
clearToastTimer();
ctx.dom.controllerDebugToast.classList.add('hidden');
ctx.dom.controllerDebugToast.classList.remove('error');
}
function showToast(message: string, isError: boolean = false): void {
clearToastTimer();
ctx.dom.controllerDebugToast.textContent = message;
ctx.dom.controllerDebugToast.classList.remove('hidden');
if (isError) {
ctx.dom.controllerDebugToast.classList.add('error');
} else {
ctx.dom.controllerDebugToast.classList.remove('error');
}
toastTimer = setTimeout(() => {
hideToast();
}, 1800);
}
function render(): void {
const activeDevice = ctx.state.connectedGamepads.find(
(device) => device.id === ctx.state.activeGamepadId,
);
setStatus(
activeDevice?.id ??
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
);
ctx.dom.controllerDebugSummary.textContent =
ctx.state.connectedGamepads.length > 0
? ctx.state.connectedGamepads
.map((device) => {
const tags = [
`#${device.index}`,
device.mapping,
device.id === ctx.state.activeGamepadId ? 'active' : null,
].filter(Boolean);
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
})
.join('\n')
: 'Connect a controller and press any button to populate raw input values.';
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
ctx.state.controllerConfig?.buttonIndices ?? null,
);
}
async function copyButtonIndicesToClipboard(): Promise<void> {
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
if (text.length === 0 || text === 'No controller config loaded.') {
setStatus('No buttonIndices config available to copy.', true);
showToast('No buttonIndices config available to copy.', true);
return;
}
try {
await writeTextToClipboard(text);
setStatus('Copied controller buttonIndices config.');
showToast('Copied controller buttonIndices config.');
} catch {
setStatus('Failed to copy controller buttonIndices config.', true);
showToast('Failed to copy controller buttonIndices config.', true);
}
}
function openControllerDebugModal(): void {
ctx.state.controllerDebugModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.controllerDebugModal.classList.remove('hidden');
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
hideToast();
render();
}
function closeControllerDebugModal(): void {
if (!ctx.state.controllerDebugModalOpen) return;
ctx.state.controllerDebugModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerDebugModal.classList.add('hidden');
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
hideToast();
window.electronAPI.notifyOverlayModalClosed('controller-debug');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
closeControllerDebugModal();
return true;
}
return true;
}
function updateSnapshot(): void {
if (!ctx.state.controllerDebugModalOpen) return;
render();
}
function wireDomEvents(): void {
ctx.dom.controllerDebugClose.addEventListener('click', () => {
closeControllerDebugModal();
});
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
void copyButtonIndicesToClipboard();
});
}
return {
openControllerDebugModal,
closeControllerDebugModal,
handleControllerDebugKeydown,
updateSnapshot,
wireDomEvents,
};
}

View File

@@ -0,0 +1,727 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerSelectModal } from './controller-select.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return tokens.has(entry);
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
test('controller select modal saves the selected preferred controller', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async (update: {
preferredGamepadId: string;
preferredGamepadLabel: string;
}) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const overlayClassList = createClassList();
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-2',
preferredGamepadLabel: 'pad-2',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: overlayClassList, focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
assert.deepEqual(saved, [
{
preferredGamepadId: 'pad-2',
preferredGamepadLabel: 'pad-2',
},
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves manual selection while controller polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 0);
modal.handleControllerSelectKeydown({
key: 'ArrowDown',
preventDefault: () => {},
} as KeyboardEvent);
assert.equal(state.controllerDeviceSelectedIndex, 1);
modal.updateDevices();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal prefers active controller over saved preferred controller', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves saved status across polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
modal.updateDevices();
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal surfaces save errors without mutating saved preference', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {
throw new Error('disk write failed');
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let appendCount = 0;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {
appendCount += 1;
},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
const initialAppendCount = appendCount;
modal.updateDevices();
assert.equal(appendCount, initialAppendCount);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});

View File

@@ -0,0 +1,264 @@
import type { ModalStateReader, RendererContext } from '../context';
function clampSelectedIndex(ctx: RendererContext): void {
if (ctx.state.connectedGamepads.length === 0) {
ctx.state.controllerDeviceSelectedIndex = 0;
return;
}
ctx.state.controllerDeviceSelectedIndex = Math.min(
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
ctx.state.connectedGamepads.length - 1,
);
}
export function createControllerSelectModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let selectedControllerId: string | null = null;
let lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = '';
function getDevicesKey(): string {
return ctx.state.connectedGamepads
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
.join('||');
}
function syncSelectedControllerId(): void {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
selectedControllerId = selected?.id ?? null;
}
function syncSelectedIndexToCurrentController(): void {
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const activeIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === ctx.state.activeGamepadId,
);
if (activeIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = activeIndex;
syncSelectedControllerId();
return;
}
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
if (preferredIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
syncSelectedControllerId();
return;
}
clampSelectedIndex(ctx);
syncSelectedControllerId();
}
function setStatus(message: string, isError = false): void {
ctx.dom.controllerSelectStatus.textContent = message;
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
}
function renderList(): void {
ctx.dom.controllerSelectList.innerHTML = '';
clampSelectedIndex(ctx);
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
ctx.state.connectedGamepads.forEach((device, index) => {
const li = document.createElement('li');
li.className = 'runtime-options-list-entry';
const button = document.createElement('button');
button.type = 'button';
button.className = 'runtime-options-item runtime-options-item-button';
button.classList.toggle('active', index === ctx.state.controllerDeviceSelectedIndex);
const label = document.createElement('div');
label.className = 'runtime-options-label';
label.textContent = device.id || `Gamepad ${device.index}`;
const meta = document.createElement('div');
meta.className = 'runtime-options-value';
const tags = [
`Index ${device.index}`,
device.mapping || 'unknown mapping',
device.id === ctx.state.activeGamepadId ? 'active' : null,
device.id === preferredId ? 'saved' : null,
].filter(Boolean);
meta.textContent = tags.join(' · ');
button.appendChild(label);
button.appendChild(meta);
button.addEventListener('click', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
renderList();
});
button.addEventListener('dblclick', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
void saveSelectedController();
});
li.appendChild(button);
ctx.dom.controllerSelectList.appendChild(li);
});
lastRenderedDevicesKey = getDevicesKey();
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
lastRenderedPreferredId = preferredId;
}
function updateDevices(): void {
if (!ctx.state.controllerSelectModalOpen) return;
if (selectedControllerId) {
const preservedIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === selectedControllerId,
);
if (preservedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
} else {
syncSelectedIndexToCurrentController();
}
} else {
syncSelectedIndexToCurrentController();
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
preferredId !== lastRenderedPreferredId;
if (shouldRender) {
renderList();
}
if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.');
return;
}
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
if (
currentStatus !== 'No controller selected.' &&
!currentStatus.startsWith('Saved preferred controller:')
) {
setStatus('Select a controller to save as preferred.');
}
}
async function saveSelectedController(): Promise<void> {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
if (!selected) {
setStatus('No controller selected.', true);
return;
}
try {
await window.electronAPI.saveControllerPreference({
preferredGamepadId: selected.id,
preferredGamepadLabel: selected.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save preferred controller: ${message}`, true);
return;
}
if (ctx.state.controllerConfig) {
ctx.state.controllerConfig.preferredGamepadId = selected.id;
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
}
syncSelectedControllerId();
renderList();
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
}
function openControllerSelectModal(): void {
ctx.state.controllerSelectModalOpen = true;
syncSelectedIndexToCurrentController();
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.controllerSelectModal.classList.remove('hidden');
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
window.focus();
ctx.dom.overlay.focus({ preventScroll: true });
renderList();
if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.');
} else {
setStatus('Select a controller to save as preferred.');
}
}
function closeControllerSelectModal(): void {
if (!ctx.state.controllerSelectModalOpen) return;
ctx.state.controllerSelectModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden');
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('controller-select');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
closeControllerSelectModal();
return true;
}
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
event.preventDefault();
if (ctx.state.connectedGamepads.length > 0) {
ctx.state.controllerDeviceSelectedIndex = Math.min(
ctx.state.connectedGamepads.length - 1,
ctx.state.controllerDeviceSelectedIndex + 1,
);
syncSelectedControllerId();
renderList();
}
return true;
}
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
event.preventDefault();
if (ctx.state.connectedGamepads.length > 0) {
ctx.state.controllerDeviceSelectedIndex = Math.max(
0,
ctx.state.controllerDeviceSelectedIndex - 1,
);
syncSelectedControllerId();
renderList();
}
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
void saveSelectedController();
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.controllerSelectClose.addEventListener('click', () => {
closeControllerSelectModal();
});
ctx.dom.controllerSelectSave.addEventListener('click', () => {
void saveSelectedController();
});
}
return {
openControllerSelectModal,
closeControllerSelectModal,
handleControllerSelectKeydown,
updateDevices,
wireDomEvents,
};
}

View File

@@ -26,7 +26,11 @@ import type {
ConfigHotReloadPayload,
} from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createGamepadController } from './handlers/gamepad-controller.js';
import { createMouseHandlers } from './handlers/mouse.js';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js';
@@ -36,6 +40,7 @@ import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible } from './yomitan-popup.js';
import {
createRendererRecoveryController,
registerRendererGlobalErrorHandlers,
@@ -55,6 +60,8 @@ const ctx = {
function isAnySettingsModalOpen(): boolean {
return (
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
function isAnyModalOpen(): boolean {
return (
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerSelectModal = createControllerSelectModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerDebugModal = createControllerDebugModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
},
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
},
openControllerDebugModal: () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
},
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
let lastSubtitlePreview = '';
let lastSecondarySubtitlePreview = '';
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
let controllerAnimationFrameId: number | null = null;
function truncateForErrorLog(text: string): string {
const normalized = text.replace(/\s+/g, ' ').trim();
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
}
function getActiveModal(): string | null {
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
}
function dismissActiveUiAfterError(): void {
if (ctx.state.controllerSelectModalOpen) {
controllerSelectModal.closeControllerSelectModal();
}
if (ctx.state.controllerDebugModalOpen) {
controllerDebugModal.closeControllerDebugModal();
}
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
syncSettingsModalSubtitleSuppression();
}
function applyControllerSnapshot(snapshot: {
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
activeGamepadId: string | null;
rawAxes: number[];
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
}): void {
controllerStatusIndicator.update({
connectedGamepads: snapshot.connectedGamepads,
activeGamepadId: snapshot.activeGamepadId,
});
ctx.state.connectedGamepads = snapshot.connectedGamepads;
ctx.state.activeGamepadId = snapshot.activeGamepadId;
ctx.state.controllerRawAxes = snapshot.rawAxes;
ctx.state.controllerRawButtons = snapshot.rawButtons;
controllerSelectModal.updateDevices();
controllerDebugModal.updateSnapshot();
}
function emitControllerPopupScroll(deltaPixels: number): void {
if (deltaPixels === 0) return;
keyboardHandlers.scrollPopupByController(0, deltaPixels);
}
function emitControllerPopupJump(deltaPixels: number): void {
if (deltaPixels === 0) return;
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
}
function startControllerPolling(): void {
if (controllerAnimationFrameId !== null) {
cancelAnimationFrame(controllerAnimationFrameId);
controllerAnimationFrameId = null;
}
const gamepadController = createGamepadController({
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
getConfig: () =>
ctx.state.controllerConfig ?? {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
},
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
getInteractionBlocked: () => isAnyModalOpen(),
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
closeLookup: () => {
keyboardHandlers.closeLookupWindow();
},
moveSelection: (delta) => {
keyboardHandlers.moveSelectionForController(delta);
},
mineCard: () => {
keyboardHandlers.mineSelectedFromController();
},
quitMpv: () => {
window.electronAPI.sendMpvCommand(['quit']);
},
previousAudio: () => {
keyboardHandlers.cyclePopupAudioSourceForController(-1);
},
nextAudio: () => {
keyboardHandlers.cyclePopupAudioSourceForController(1);
},
playCurrentAudio: () => {
keyboardHandlers.playCurrentAudioForController();
},
toggleMpvPause: () => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
},
scrollPopup: (deltaPixels) => {
emitControllerPopupScroll(deltaPixels);
},
jumpPopup: (deltaPixels) => {
emitControllerPopupJump(deltaPixels);
},
onState: (snapshot) => {
applyControllerSnapshot(snapshot);
},
});
const poll = (now: number): void => {
gamepadController.poll(now);
controllerAnimationFrameId = requestAnimationFrame(poll);
};
controllerAnimationFrameId = requestAnimationFrame(poll);
}
function restoreOverlayInteractionAfterError(): void {
ctx.state.isOverSubtitle = false;
ctx.dom.overlay.classList.remove('interactive');
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
});
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents();
controllerSelectModal.wireDomEvents();
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding();
try {
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
} catch (error) {
console.error('Failed to load controller config.', error);
ctx.state.controllerConfig = null;
}
startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);

View File

@@ -1,4 +1,7 @@
import type {
ControllerButtonSnapshot,
ControllerDeviceInfo,
ResolvedControllerConfig,
JimakuEntry,
JimakuFileEntry,
KikuDuplicateCardInfo,
@@ -53,6 +56,15 @@ export type RendererState = {
subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubmitting: boolean;
controllerSelectModalOpen: boolean;
controllerDebugModalOpen: boolean;
controllerDeviceSelectedIndex: number;
controllerConfig: ResolvedControllerConfig | null;
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
controllerRawAxes: number[];
controllerRawButtons: ControllerButtonSnapshot[];
sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number;
@@ -82,6 +94,7 @@ export type RendererState = {
chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null;
keyboardDrivenModeEnabled: boolean;
keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
};
@@ -122,6 +135,15 @@ export function createRendererState(): RendererState {
subsyncSourceTracks: [],
subsyncSubmitting: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
controllerDeviceSelectedIndex: 0,
controllerConfig: null,
connectedGamepads: [],
activeGamepadId: null,
controllerRawAxes: [],
controllerRawButtons: [],
sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0,
@@ -151,6 +173,7 @@ export function createRendererState(): RendererState {
chordPending: false,
chordTimeout: null,
keyboardDrivenModeEnabled: false,
keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
};

View File

@@ -55,6 +55,34 @@ body {
pointer-events: auto;
}
.controller-status-toast {
position: absolute;
top: 16px;
left: 16px;
max-width: min(360px, calc(100vw - 32px));
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(138, 213, 202, 0.45);
background:
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
color: rgba(228, 255, 251, 0.98);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 160ms ease,
transform 160ms ease;
z-index: 1300;
}
.controller-status-toast:not(.hidden) {
opacity: 1;
transform: translateY(0);
}
.overlay-error-toast {
position: absolute;
top: 16px;
@@ -321,6 +349,12 @@ body.settings-modal-open #subtitleContainer {
pointer-events: none !important;
}
body.settings-modal-open iframe.yomitan-popup,
body.settings-modal-open iframe[id^='yomitan-popup'] {
display: none !important;
pointer-events: none !important;
}
#subtitleRoot .c {
display: inline;
position: relative;
@@ -1013,6 +1047,10 @@ iframe[id^='yomitan-popup'] {
overflow-y: auto;
}
.runtime-options-list-entry {
list-style: none;
}
.runtime-options-item {
display: flex;
flex-direction: column;
@@ -1022,7 +1060,15 @@ iframe[id^='yomitan-popup'] {
cursor: pointer;
}
.runtime-options-item:last-child {
.runtime-options-item-button {
width: 100%;
border: none;
background: transparent;
text-align: left;
color: inherit;
}
.runtime-options-list-entry:last-child .runtime-options-item {
border-bottom: none;
}
@@ -1030,6 +1076,11 @@ iframe[id^='yomitan-popup'] {
background: rgba(100, 180, 255, 0.15);
}
.runtime-options-item-button:focus-visible {
outline: 2px solid rgba(100, 180, 255, 0.85);
outline-offset: -2px;
}
.runtime-options-label {
font-size: 14px;
color: #fff;
@@ -1055,12 +1106,84 @@ iframe[id^='yomitan-popup'] {
color: #ff8f8f;
}
.controller-debug-content {
position: relative;
width: min(760px, 94%);
}
.controller-debug-toast {
position: absolute;
top: 18px;
right: 56px;
z-index: 2;
max-width: min(320px, calc(100% - 88px));
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 214, 168, 0.34);
background: rgba(20, 38, 30, 0.96);
color: rgba(220, 255, 232, 0.98);
font-size: 12px;
line-height: 1.3;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
.controller-debug-toast.error {
border-color: rgba(255, 143, 143, 0.34);
background: rgba(52, 22, 24, 0.96);
color: rgba(255, 225, 225, 0.98);
}
.controller-debug-summary {
min-height: 18px;
font-size: 13px;
color: rgba(255, 255, 255, 0.86);
line-height: 1.45;
}
.controller-debug-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.controller-debug-span {
grid-column: 1 / -1;
}
.controller-debug-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.controller-debug-pre {
min-height: 220px;
margin: 0;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.38);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
line-height: 1.5;
overflow: auto;
white-space: pre-wrap;
}
.session-help-content {
width: min(760px, 92%);
max-height: 84%;
color: rgba(255, 255, 255, 0.95);
}
@media (max-width: 720px) {
.controller-debug-grid {
grid-template-columns: 1fr;
}
}
.session-help-shortcut,
.session-help-warning,
.session-help-status {

View File

@@ -2,6 +2,7 @@ export type RendererDom = {
subtitleRoot: HTMLElement;
subtitleContainer: HTMLElement;
overlay: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement;
secondarySubContainer: HTMLElement;
secondarySubRoot: HTMLElement;
@@ -56,6 +57,23 @@ export type RendererDom = {
subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement;
controllerSelectModal: HTMLDivElement;
controllerSelectClose: HTMLButtonElement;
controllerSelectHint: HTMLDivElement;
controllerSelectStatus: HTMLDivElement;
controllerSelectList: HTMLUListElement;
controllerSelectSave: HTMLButtonElement;
controllerDebugModal: HTMLDivElement;
controllerDebugClose: HTMLButtonElement;
controllerDebugCopy: HTMLButtonElement;
controllerDebugToast: HTMLDivElement;
controllerDebugStatus: HTMLDivElement;
controllerDebugSummary: HTMLDivElement;
controllerDebugAxes: HTMLPreElement;
controllerDebugButtons: HTMLPreElement;
controllerDebugButtonIndices: HTMLPreElement;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement;
@@ -78,6 +96,7 @@ export function resolveRendererDom(): RendererDom {
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
overlay: getRequiredElement<HTMLElement>('overlay'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
@@ -132,6 +151,23 @@ export function resolveRendererDom(): RendererDom {
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'),
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'),
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
controllerDebugClose: getRequiredElement<HTMLButtonElement>('controllerDebugClose'),
controllerDebugCopy: getRequiredElement<HTMLButtonElement>('controllerDebugCopy'),
controllerDebugToast: getRequiredElement<HTMLDivElement>('controllerDebugToast'),
controllerDebugStatus: getRequiredElement<HTMLDivElement>('controllerDebugStatus'),
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
// @ts-expect-error Vendor Yomitan modules are JS-only in this repo.
import { Display } from '../../vendor/subminer-yomitan/ext/js/display/display.js';
test('yomitan display scroll bridge uses popup scroll container instead of window scroll', () => {
let scrolledTo: { x: number; y: number } | null = null;
const result = Display.prototype._onMessageScrollBy.call(
{
_windowScroll: {
x: 24,
y: 80,
to(x: number, y: number) {
scrolledTo = { x, y };
},
},
},
{ deltaX: 12, deltaY: -20 },
);
assert.equal(result, true);
assert.deepEqual(scrolledTo, { x: 36, y: 60 });
});

View File

@@ -1,6 +1,13 @@
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const;
export const OVERLAY_HOSTED_MODALS = [
'runtime-options',
'subsync',
'jimaku',
'kiku',
'controller-select',
'controller-debug',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
export const IPC_CHANNELS = {
@@ -12,6 +19,7 @@ export const IPC_CHANNELS = {
toggleDevTools: 'toggle-dev-tools',
toggleOverlay: 'toggle-overlay',
saveSubtitlePosition: 'save-subtitle-position',
saveControllerPreference: 'save-controller-preference',
setMecabEnabled: 'set-mecab-enabled',
mpvCommand: 'mpv-command',
setAnkiConnectEnabled: 'set-anki-connect-enabled',
@@ -32,6 +40,7 @@ export const IPC_CHANNELS = {
getMecabStatus: 'get-mecab-status',
getKeybindings: 'get-keybindings',
getConfigShortcuts: 'get-config-shortcuts',
getControllerConfig: 'get-controller-config',
getSecondarySubMode: 'get-secondary-sub-mode',
getCurrentSecondarySub: 'get-current-secondary-sub',
focusMainWindow: 'focus-main-window',

View File

@@ -1,4 +1,5 @@
import type {
ControllerPreferenceUpdate,
JimakuDownloadQuery,
JimakuFilesQuery,
JimakuSearchQuery,
@@ -48,6 +49,16 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
};
}
export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null {
if (!isObject(value)) return null;
if (typeof value.preferredGamepadId !== 'string') return null;
if (typeof value.preferredGamepadLabel !== 'string') return null;
return {
preferredGamepadId: value.preferredGamepadId,
preferredGamepadLabel: value.preferredGamepadLabel,
};
}
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
if (!isObject(value)) return null;
const { engine, sourceTrackId } = value;

View File

@@ -375,6 +375,94 @@ export interface ShortcutsConfig {
openJimaku?: string | null;
}
export type ControllerButtonBinding =
| 'none'
| 'select'
| 'buttonSouth'
| 'buttonEast'
| 'buttonNorth'
| 'buttonWest'
| 'leftShoulder'
| 'rightShoulder'
| 'leftStickPress'
| 'rightStickPress'
| 'leftTrigger'
| 'rightTrigger';
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
export interface ControllerBindingsConfig {
toggleLookup?: ControllerButtonBinding;
closeLookup?: ControllerButtonBinding;
toggleKeyboardOnlyMode?: ControllerButtonBinding;
mineCard?: ControllerButtonBinding;
quitMpv?: ControllerButtonBinding;
previousAudio?: ControllerButtonBinding;
nextAudio?: ControllerButtonBinding;
playCurrentAudio?: ControllerButtonBinding;
toggleMpvPause?: ControllerButtonBinding;
leftStickHorizontal?: ControllerAxisBinding;
leftStickVertical?: ControllerAxisBinding;
rightStickHorizontal?: ControllerAxisBinding;
rightStickVertical?: ControllerAxisBinding;
}
export interface ControllerButtonIndicesConfig {
select?: number;
buttonSouth?: number;
buttonEast?: number;
buttonNorth?: number;
buttonWest?: number;
leftShoulder?: number;
rightShoulder?: number;
leftStickPress?: number;
rightStickPress?: number;
leftTrigger?: number;
rightTrigger?: number;
}
export interface ControllerConfig {
enabled?: boolean;
preferredGamepadId?: string;
preferredGamepadLabel?: string;
smoothScroll?: boolean;
scrollPixelsPerSecond?: number;
horizontalJumpPixels?: number;
stickDeadzone?: number;
triggerInputMode?: ControllerTriggerInputMode;
triggerDeadzone?: number;
repeatDelayMs?: number;
repeatIntervalMs?: number;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
}
export interface ControllerPreferenceUpdate {
preferredGamepadId: string;
preferredGamepadLabel: string;
}
export interface ControllerDeviceInfo {
id: string;
index: number;
mapping: string;
connected: boolean;
}
export interface ControllerButtonSnapshot {
value: number;
pressed: boolean;
touched?: boolean;
}
export interface ControllerRuntimeSnapshot {
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
rawAxes: number[];
rawButtons: ControllerButtonSnapshot[];
}
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export interface JimakuConfig {
@@ -487,6 +575,7 @@ export interface Config {
websocket?: WebSocketConfig;
annotationWebsocket?: AnnotationWebSocketConfig;
texthooker?: TexthookerConfig;
controller?: ControllerConfig;
ankiConnect?: AnkiConnectConfig;
shortcuts?: ShortcutsConfig;
secondarySub?: SecondarySubConfig;
@@ -514,6 +603,21 @@ export interface ResolvedConfig {
websocket: Required<WebSocketConfig>;
annotationWebsocket: Required<AnnotationWebSocketConfig>;
texthooker: Required<TexthookerConfig>;
controller: {
enabled: boolean;
preferredGamepadId: string;
preferredGamepadLabel: string;
smoothScroll: boolean;
scrollPixelsPerSecond: number;
horizontalJumpPixels: number;
stickDeadzone: number;
triggerInputMode: ControllerTriggerInputMode;
triggerDeadzone: number;
repeatDelayMs: number;
repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ControllerBindingsConfig>;
};
ankiConnect: AnkiConnectConfig & {
enabled: boolean;
url: string;
@@ -838,6 +942,8 @@ export interface ConfigHotReloadPayload {
secondarySubMode: SecondarySubMode;
}
export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface SubtitleHoverTokenPayload {
tokenIndex: number | null;
}
@@ -862,6 +968,8 @@ export interface ElectronAPI {
sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
@@ -895,8 +1003,24 @@ export interface ElectronAPI {
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
notifyOverlayModalClosed: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug',
) => void;
notifyOverlayModalOpened: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}