mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
68833c76c4
|
|||
| 4d7c80f2e4 |
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.2 (2026-03-12)
|
||||
|
||||
### Changed
|
||||
- Config: Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode.
|
||||
- Config: SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile.
|
||||
- Config: Launcher-managed playback now respects `yomitan.externalProfilePath` and no longer forces first-run setup when external Yomitan is configured.
|
||||
- Config: SubMiner now seeds `config.jsonc` even when the default config directory already exists.
|
||||
- Config: First-run setup now allows zero internal dictionaries when `yomitan.externalProfilePath` is configured, and falls back to requiring at least one internal dictionary if that external profile is later removed.
|
||||
|
||||
## v0.6.1 (2026-03-12)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -336,17 +385,6 @@
|
||||
} // Character dictionary setting.
|
||||
}, // Anilist API credentials and update behavior.
|
||||
|
||||
// ==========================================
|
||||
// Yomitan
|
||||
// Optional external Yomitan profile integration.
|
||||
// Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.
|
||||
// For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.
|
||||
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
||||
// ==========================================
|
||||
"yomitan": {
|
||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||
}, // Optional external Yomitan profile integration.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.2 (2026-03-12)
|
||||
- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode.
|
||||
- Reused external Yomitan dictionaries/settings without writing back to that profile.
|
||||
- Let launcher-managed playback honor external Yomitan config instead of forcing first-run setup.
|
||||
- Seeded `config.jsonc` even when the default config directory already exists.
|
||||
- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -62,10 +62,6 @@ Character dictionary sync is disabled by default. To turn it on:
|
||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
|
||||
:::
|
||||
|
||||
::: warning
|
||||
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
|
||||
:::
|
||||
|
||||
## Name Generation
|
||||
|
||||
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
|
||||
|
||||
@@ -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
|
||||
@@ -112,7 +113,6 @@ The configuration file includes several main sections:
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
|
||||
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||
@@ -504,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:
|
||||
@@ -935,33 +1017,6 @@ AniList CLI commands:
|
||||
- `--anilist-setup`: open AniList setup/auth flow helper window.
|
||||
- `--anilist-retry-queue`: process one ready retry queue item immediately.
|
||||
|
||||
### Yomitan
|
||||
|
||||
SubMiner normally uses its bundled Yomitan profile under the app config directory. If you want to reuse dictionaries and profile settings from another Electron app, point SubMiner at that app's Yomitan Electron profile in read-only mode.
|
||||
|
||||
For GameSentenceMiner on Linux, the default overlay profile path is typically `~/.config/gsm_overlay`.
|
||||
|
||||
```json
|
||||
{
|
||||
"yomitan": {
|
||||
"externalProfilePath": "/home/you/.config/gsm_overlay"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
|
||||
|
||||
External-profile mode behavior:
|
||||
|
||||
- SubMiner uses the external profile's Yomitan extension/session instead of its local copy.
|
||||
- SubMiner reads the external profile's currently active Yomitan profile selection and installed dictionaries.
|
||||
- SubMiner does not open its own Yomitan settings window in this mode.
|
||||
- SubMiner does not import, delete, or update dictionaries/settings in the external profile.
|
||||
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
|
||||
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
|
||||
|
||||
### Jellyfin
|
||||
|
||||
Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch.
|
||||
|
||||
@@ -59,6 +59,22 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
||||
3. Yomitan detects the selection and opens its lookup popup.
|
||||
4. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
|
||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
|
||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
|
||||
## Creating Anki Cards
|
||||
|
||||
There are three ways to create cards, depending on your workflow.
|
||||
|
||||
@@ -50,6 +50,55 @@
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// Controller Support
|
||||
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
// Use the selection modal to save a preferred controller by id for future launches.
|
||||
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||
// ==========================================
|
||||
"controller": {
|
||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
|
||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||
}, // Button indices setting.
|
||||
"bindings": {
|
||||
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||
} // Bindings setting.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
// Startup Warmups
|
||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||
@@ -336,17 +385,6 @@
|
||||
} // Character dictionary setting.
|
||||
}, // Anilist API credentials and update behavior.
|
||||
|
||||
// ==========================================
|
||||
// Yomitan
|
||||
// Optional external Yomitan profile integration.
|
||||
// Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.
|
||||
// For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.
|
||||
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
||||
// ==========================================
|
||||
"yomitan": {
|
||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||
}, // Optional external Yomitan profile integration.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -182,7 +182,6 @@ If you installed from the AppImage and see this error, the package may be incomp
|
||||
|
||||
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
||||
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
|
||||
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
||||
- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below.
|
||||
|
||||
## MeCab / Tokenization
|
||||
|
||||
@@ -246,6 +246,45 @@ Notes:
|
||||
- `--whisper-threads`
|
||||
- `--yt-subgen-audio-format`
|
||||
|
||||
## Controller Support
|
||||
|
||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Connect a controller before or after launching SubMiner.
|
||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||
3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup.
|
||||
4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||
|
||||
By default SubMiner uses the first connected controller. Press `Alt+C` in the overlay to open the controller selection modal and persist your preferred controller across sessions. Press `Alt+Shift+C` to open a live debug modal showing raw axes and button values.
|
||||
|
||||
### Default Button Mapping
|
||||
|
||||
| Button | Action |
|
||||
| ------ | ------ |
|
||||
| `A` (South) | Toggle lookup |
|
||||
| `B` (East) | Close lookup |
|
||||
| `Y` (North) | Toggle keyboard-only mode |
|
||||
| `X` (West) | Mine card |
|
||||
| `L1` | Play current Yomitan audio |
|
||||
| `R1` | Next Yomitan audio track |
|
||||
| `L3` (left stick press) | Toggle mpv pause |
|
||||
| `Select` / `Minus` | Quit mpv |
|
||||
| `L2` / `R2` | Unbound (available for custom bindings) |
|
||||
|
||||
### Analog Controls
|
||||
|
||||
| Input | Action |
|
||||
| ----- | ------ |
|
||||
| Left stick horizontal | Move token selection left/right |
|
||||
| Left stick vertical | Smooth scroll Yomitan popup |
|
||||
| Right stick horizontal | Jump inside popup (horizontal) |
|
||||
| Right stick vertical | Smooth scroll popup (vertical) |
|
||||
| D-pad | Fallback for stick navigation |
|
||||
|
||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||
|
||||
## Keybindings
|
||||
|
||||
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
||||
|
||||
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Overlay Controller Support Design
|
||||
|
||||
**Date:** 2026-03-11
|
||||
**Backlog:** `TASK-159`
|
||||
|
||||
## Goal
|
||||
|
||||
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
|
||||
|
||||
## Scope
|
||||
|
||||
- Poll connected gamepads from the visible overlay renderer.
|
||||
- Default to the first connected controller unless config specifies a preferred controller.
|
||||
- Add logical controller bindings and tuning knobs to config.
|
||||
- Add `Alt+C` controller selection modal.
|
||||
- Add `Alt+Shift+C` controller debug modal.
|
||||
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
|
||||
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
|
||||
|
||||
Out of scope for this pass:
|
||||
|
||||
- Raw arbitrary axis/button index remapping in config.
|
||||
- Controller support outside the visible overlay renderer.
|
||||
- Haptics or vibration.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
|
||||
|
||||
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
|
||||
|
||||
## Behavior
|
||||
|
||||
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
|
||||
|
||||
Default logical mappings:
|
||||
|
||||
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
|
||||
- left stick horizontal: move token selection left/right
|
||||
- right stick vertical: smooth Yomitan popup/window scroll
|
||||
- right stick horizontal: jump horizontally inside Yomitan popup/window
|
||||
- `A`: toggle lookup
|
||||
- `B`: close lookup
|
||||
- `Y`: toggle keyboard-only mode
|
||||
- `X`: mine card
|
||||
- `L1` / `R1`: previous / next Yomitan audio
|
||||
- `R2`: activate current Yomitan audio button
|
||||
- `L2`: toggle mpv play/pause
|
||||
|
||||
Selection-highlight cleanup:
|
||||
|
||||
- disabling keyboard-only mode clears the selected token class immediately
|
||||
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
|
||||
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
|
||||
|
||||
## Config
|
||||
|
||||
Add a top-level `controller` block in resolved config with:
|
||||
|
||||
- `enabled`
|
||||
- `preferredGamepadId`
|
||||
- `preferredGamepadLabel`
|
||||
- `smoothScroll`
|
||||
- `scrollPixelsPerSecond`
|
||||
- `horizontalJumpPixels`
|
||||
- `stickDeadzone`
|
||||
- `triggerDeadzone`
|
||||
- `repeatDelayMs`
|
||||
- `repeatIntervalMs`
|
||||
- `bindings` logical fields for the named actions/sticks
|
||||
|
||||
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
|
||||
|
||||
## UI
|
||||
|
||||
Controller selection modal:
|
||||
|
||||
- overlay-hosted modal in the visible renderer
|
||||
- lists currently connected controllers
|
||||
- highlights current active choice
|
||||
- selecting one persists config and makes it the active controller immediately if connected
|
||||
|
||||
Controller debug modal:
|
||||
|
||||
- overlay-hosted modal
|
||||
- shows selected controller and all connected controllers
|
||||
- live raw axis array values
|
||||
- live raw button values, pressed flags, and touched flags if available
|
||||
|
||||
## Testing
|
||||
|
||||
Test first:
|
||||
|
||||
- controller gating outside keyboard-only mode
|
||||
- logical mapping to existing helpers
|
||||
- continuous stick scroll and repeat behavior
|
||||
- modal open shortcuts
|
||||
- preferred-controller selection persistence
|
||||
- highlight cleanup on keyboard-only disable and popup close
|
||||
- config defaults/parse/template generation coverage
|
||||
|
||||
## Risks
|
||||
|
||||
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
|
||||
Mitigation: match by exact preferred id first; fall back to first connected controller.
|
||||
- Continuous stick input can spam actions.
|
||||
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
|
||||
- Popup DOM/audio controls may vary.
|
||||
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.
|
||||
|
||||
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Overlay Controller Support Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
|
||||
|
||||
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Track work and lock the design
|
||||
|
||||
**Files:**
|
||||
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
|
||||
|
||||
**Step 1: Record the approved scope**
|
||||
|
||||
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
|
||||
|
||||
**Step 2: Verify the written scope matches the approved design**
|
||||
|
||||
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
|
||||
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
|
||||
|
||||
### Task 2: Add failing config tests and defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/config.test.ts`
|
||||
- Modify: `src/config/definitions/defaults-core.ts`
|
||||
- Modify: `src/config/definitions/options-core.ts`
|
||||
- Modify: `src/config/definitions/template-sections.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `config.example.jsonc`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: FAIL because `controller` config is not defined yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Add failing keyboard-selection cleanup tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/handlers/keyboard.test.ts`
|
||||
- Modify: `src/renderer/handlers/keyboard.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- turning keyboard-only mode off clears `.keyboard-selected`
|
||||
- closing the popup clears stale selection highlight when keyboard-only mode is off
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: FAIL because selection cleanup is incomplete today.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Add failing controller runtime tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- Create: `src/renderer/handlers/gamepad-controller.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- first connected controller is selected by default
|
||||
- preferred controller wins when connected
|
||||
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
|
||||
- stick/button mappings invoke the expected logical helpers
|
||||
- smooth scroll and repeat throttling behavior
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: FAIL because controller runtime does not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 5: Add failing controller modal tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/index.html`
|
||||
- Modify: `src/renderer/style.css`
|
||||
- Create: `src/renderer/modals/controller-select.ts`
|
||||
- Create: `src/renderer/modals/controller-select.test.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.test.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `Alt+C` opens controller selection modal
|
||||
- `Alt+Shift+C` opens controller debug modal
|
||||
- selection modal renders connected controllers and persists the chosen device
|
||||
- debug modal shows live axes/buttons state
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: FAIL because modals and shortcuts do not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 6: Persist controller preference through preload/main wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/preload.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `src/shared/ipc/contracts.ts`
|
||||
- Modify: `src/core/services/ipc.ts`
|
||||
- Modify: `src/main.ts`
|
||||
- Modify: related main/runtime tests as needed
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: FAIL because no controller preference IPC exists yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 7: Update docs and config example
|
||||
|
||||
**Files:**
|
||||
- Modify: `config.example.jsonc`
|
||||
- Modify: `README.md`
|
||||
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
|
||||
|
||||
**Step 1: Write the failing doc/config check if needed**
|
||||
|
||||
If config example generation is covered by tests, add/refresh the failing assertion first.
|
||||
|
||||
**Step 2: Implement the docs**
|
||||
|
||||
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
|
||||
|
||||
**Step 3: Run doc/config verification**
|
||||
|
||||
Run: `bun run test:config`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 8: Run the handoff gate and update the backlog task
|
||||
|
||||
**Files:**
|
||||
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
|
||||
**Step 1: Run targeted verification**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun test src/config/config.test.ts`
|
||||
- `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- `bun test src/renderer/modals/controller-select.test.ts`
|
||||
- `bun test src/renderer/modals/controller-debug.test.ts`
|
||||
- `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run broader gate**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
|
||||
Expected: PASS, or document exact blockers/failures.
|
||||
|
||||
**Step 3: Update backlog notes**
|
||||
|
||||
Fill in implementation notes, verification commands, and final summary in `TASK-159`.
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -102,7 +101,6 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
launchSetupApp: () => {
|
||||
const setupArgs = ['--background', '--setup'];
|
||||
if (args.logLevel) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { readExternalYomitanProfilePath } from './config.js';
|
||||
import {
|
||||
getPluginConfigCandidates,
|
||||
parsePluginRuntimeConfigContent,
|
||||
@@ -117,36 +116,3 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
test('readExternalYomitanProfilePath detects configured external profile paths', () => {
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: {
|
||||
externalProfilePath: ' ~/.config/gsm_overlay ',
|
||||
},
|
||||
}),
|
||||
'~/.config/gsm_overlay',
|
||||
);
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: {
|
||||
externalProfilePath: ' ',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: {
|
||||
externalProfilePath: 123,
|
||||
},
|
||||
} as never),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,19 +17,6 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
|
||||
export function readExternalYomitanProfilePath(root: Record<string, unknown> | null): string | null {
|
||||
const yomitan =
|
||||
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
|
||||
? (root.yomitan as Record<string, unknown>)
|
||||
: null;
|
||||
const externalProfilePath = yomitan?.externalProfilePath;
|
||||
if (typeof externalProfilePath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = externalProfilePath.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
|
||||
const root = readLauncherMainConfigObject();
|
||||
if (!root) return {};
|
||||
@@ -42,10 +29,6 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
||||
return parseLauncherJellyfinConfig(root);
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
return readPluginRuntimeConfigValue(logLevel);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 3,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -19,11 +18,10 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -56,11 +54,10 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 3,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -69,11 +66,10 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 3,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
@@ -97,33 +93,13 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady bypasses setup gate when external yomitan is configured', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => null,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 3,
|
||||
version: 2,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
|
||||
@@ -25,16 +25,12 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
if (isSetupCompleted(deps.readSetupState())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.6.2",
|
||||
"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",
|
||||
|
||||
@@ -30,7 +30,6 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
|
||||
assert.equal(config.yomitan.externalProfilePath, '');
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
@@ -1107,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, [
|
||||
@@ -1639,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":/);
|
||||
@@ -1663,6 +1792,14 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -25,13 +25,14 @@ const {
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
controller,
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
subsync,
|
||||
startupWarmups,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
controller,
|
||||
ankiConnect,
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
@@ -52,7 +54,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
anilist,
|
||||
yomitan,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,14 +2,7 @@ import { ResolvedConfig } from '../../types';
|
||||
|
||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'anilist'
|
||||
| 'yomitan'
|
||||
| 'jellyfin'
|
||||
| 'discordPresence'
|
||||
| 'ai'
|
||||
| 'youtubeSubgen'
|
||||
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen'
|
||||
> = {
|
||||
ankiConnect: {
|
||||
enabled: false,
|
||||
@@ -101,9 +94,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
},
|
||||
},
|
||||
yomitan: {
|
||||
externalProfilePath: '',
|
||||
},
|
||||
jellyfin: {
|
||||
enabled: false,
|
||||
serverUrl: '',
|
||||
|
||||
@@ -19,13 +19,14 @@ 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',
|
||||
'ankiConnect.enabled',
|
||||
'anilist.characterDictionary.enabled',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
@@ -39,10 +40,10 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'controller',
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'yomitan',
|
||||
'immersionTracking',
|
||||
];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -211,13 +211,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Open the Voiced by section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'yomitan.externalProfilePath',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.yomitan.externalProfilePath,
|
||||
description:
|
||||
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
key: 'logging',
|
||||
},
|
||||
{
|
||||
title: 'Controller Support',
|
||||
description: [
|
||||
'Gamepad support for the visible overlay while keyboard-only mode is active.',
|
||||
'Use the selection modal to save a preferred controller by id for future launches.',
|
||||
'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.',
|
||||
'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
|
||||
],
|
||||
key: 'controller',
|
||||
},
|
||||
{
|
||||
title: 'Startup Warmups',
|
||||
description: [
|
||||
@@ -127,16 +137,6 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'anilist',
|
||||
},
|
||||
{
|
||||
title: 'Yomitan',
|
||||
description: [
|
||||
'Optional external Yomitan profile integration.',
|
||||
'Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.',
|
||||
'For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.',
|
||||
'In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.',
|
||||
],
|
||||
key: 'yomitan',
|
||||
},
|
||||
{
|
||||
title: 'Jellyfin',
|
||||
description: [
|
||||
|
||||
@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
const controllerButtonBindings = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
@@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller)) {
|
||||
const enabled = asBoolean(src.controller.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.controller.enabled = enabled;
|
||||
} else if (src.controller.enabled !== undefined) {
|
||||
warn(
|
||||
'controller.enabled',
|
||||
src.controller.enabled,
|
||||
resolved.controller.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||
if (preferredGamepadId !== undefined) {
|
||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||
}
|
||||
|
||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||
if (preferredGamepadLabel !== undefined) {
|
||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||
}
|
||||
|
||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||
if (smoothScroll !== undefined) {
|
||||
resolved.controller.smoothScroll = smoothScroll;
|
||||
} else if (src.controller.smoothScroll !== undefined) {
|
||||
warn(
|
||||
'controller.smoothScroll',
|
||||
src.controller.smoothScroll,
|
||||
resolved.controller.smoothScroll,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||
if (
|
||||
triggerInputMode === 'auto' ||
|
||||
triggerInputMode === 'digital' ||
|
||||
triggerInputMode === 'analog'
|
||||
) {
|
||||
resolved.controller.triggerInputMode = triggerInputMode;
|
||||
} else if (src.controller.triggerInputMode !== undefined) {
|
||||
warn(
|
||||
'controller.triggerInputMode',
|
||||
src.controller.triggerInputMode,
|
||||
resolved.controller.triggerInputMode,
|
||||
"Expected 'auto', 'digital', or 'analog'.",
|
||||
);
|
||||
}
|
||||
|
||||
const boundedNumberKeys = [
|
||||
'scrollPixelsPerSecond',
|
||||
'horizontalJumpPixels',
|
||||
'repeatDelayMs',
|
||||
'repeatIntervalMs',
|
||||
] as const;
|
||||
for (const key of boundedNumberKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && Math.floor(value) > 0) {
|
||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
|
||||
}
|
||||
}
|
||||
|
||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||
for (const key of deadzoneKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && value >= 0 && value <= 1) {
|
||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.buttonIndices)) {
|
||||
const buttonIndexKeys = [
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonIndexKeys) {
|
||||
const value = asNumber(src.controller.buttonIndices[key]);
|
||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||
resolved.controller.buttonIndices[key] = value;
|
||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||
warn(
|
||||
`controller.buttonIndices.${key}`,
|
||||
src.controller.buttonIndices[key],
|
||||
resolved.controller.buttonIndices[key],
|
||||
'Expected non-negative integer.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.bindings)) {
|
||||
const buttonBindingKeys = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const axisBindingKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
for (const key of axisBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ResolveContext } from './context';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
function normalizeExternalProfilePath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -212,22 +199,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.yomitan)) {
|
||||
const externalProfilePath = asString(src.yomitan.externalProfilePath);
|
||||
if (externalProfilePath !== undefined) {
|
||||
resolved.yomitan.externalProfilePath = normalizeExternalProfilePath(externalProfilePath);
|
||||
} else if (src.yomitan.externalProfilePath !== undefined) {
|
||||
warn(
|
||||
'yomitan.externalProfilePath',
|
||||
src.yomitan.externalProfilePath,
|
||||
resolved.yomitan.externalProfilePath,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
} else if (src.yomitan !== undefined) {
|
||||
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.jellyfin)) {
|
||||
const enabled = asBoolean(src.jellyfin.enabled);
|
||||
if (enabled !== undefined) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createResolveContext } from './context';
|
||||
import { applyIntegrationConfig } from './integrations';
|
||||
|
||||
@@ -106,42 +104,3 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
|
||||
warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'),
|
||||
);
|
||||
});
|
||||
|
||||
test('yomitan externalProfilePath is trimmed and invalid values warn', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
yomitan: {
|
||||
externalProfilePath: ' /tmp/gsm-profile ',
|
||||
},
|
||||
});
|
||||
|
||||
applyIntegrationConfig(context);
|
||||
|
||||
assert.equal(context.resolved.yomitan.externalProfilePath, '/tmp/gsm-profile');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
yomitan: {
|
||||
externalProfilePath: 42 as never,
|
||||
},
|
||||
});
|
||||
|
||||
applyIntegrationConfig(invalid.context);
|
||||
|
||||
assert.equal(invalid.context.resolved.yomitan.externalProfilePath, '');
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'yomitan.externalProfilePath'));
|
||||
});
|
||||
|
||||
test('yomitan externalProfilePath expands leading tilde to the current home directory', () => {
|
||||
const homeDir = os.homedir();
|
||||
const { context } = createResolveContext({
|
||||
yomitan: {
|
||||
externalProfilePath: '~/.config/gsm_overlay',
|
||||
},
|
||||
});
|
||||
|
||||
applyIntegrationConfig(context);
|
||||
|
||||
assert.equal(
|
||||
context.resolved.yomitan.externalProfilePath,
|
||||
path.join(homeDir, '.config', 'gsm_overlay'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
|
||||
assert.equal(calls.includes('reloadConfig'), true);
|
||||
assert.equal(calls.includes('reloadConfig'), false);
|
||||
assert.equal(calls.includes('getResolvedConfig'), false);
|
||||
assert.equal(calls.includes('getConfigWarnings'), false);
|
||||
assert.equal(calls.includes('setLogLevel:warn:config'), false);
|
||||
@@ -170,8 +170,6 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
assert.equal(calls.includes('loadYomitanExtension'), true);
|
||||
assert.equal(calls.includes('handleFirstRunSetup'), true);
|
||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('reloadConfig'));
|
||||
assert.ok(calls.indexOf('reloadConfig') < calls.indexOf('handleFirstRunSetup'));
|
||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
|
||||
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
@@ -53,6 +53,48 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
@@ -117,6 +159,48 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||
assert.ok(getPlaybackPausedHandler);
|
||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
||||
|
||||
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
|
||||
assert.ok(getControllerConfigHandler);
|
||||
assert.equal(
|
||||
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
|
||||
960,
|
||||
);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
const controllerSaves: unknown[] = [];
|
||||
const closedModals: unknown[] = [];
|
||||
const openedModals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
@@ -207,6 +299,50 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const controllerSaves: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getPlaybackPaused: () => false,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||
setMecabEnabled: () => {},
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: async (update) => {
|
||||
await Promise.resolve();
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerSaves, [
|
||||
{
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(
|
||||
{
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getPlaybackPaused: () => false,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||
setMecabEnabled: () => {},
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getControllerConfig: () => ({
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
}),
|
||||
saveControllerPreference: async () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import electron from 'electron';
|
||||
import type { IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
@@ -10,6 +12,7 @@ import type {
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
parseControllerPreferenceUpdate,
|
||||
parseOptionalForwardingOptions,
|
||||
parseOverlayHostedModal,
|
||||
parseRuntimeOptionDirection,
|
||||
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
handleMpvCommand: options.handleMpvCommand,
|
||||
getKeybindings: options.getKeybindings,
|
||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
getSecondarySubMode: options.getSecondarySubMode,
|
||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||
focusMainWindow: () => {
|
||||
@@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.saveSubtitlePosition(parsedPosition);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
|
||||
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller preference payload');
|
||||
}
|
||||
await deps.saveControllerPreference(parsedUpdate);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
return deps.getMecabStatus();
|
||||
});
|
||||
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getConfiguredShortcuts();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||
return deps.getSecondarySubMode();
|
||||
});
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
|
||||
const options = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
assert.equal(options.webPreferences?.sandbox, false);
|
||||
});
|
||||
|
||||
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||
const yomitanSession = { id: 'session' } as never;
|
||||
const withSession = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession,
|
||||
});
|
||||
const withoutSession = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(withSession.webPreferences?.session, yomitanSession);
|
||||
assert.equal(withoutSession.webPreferences?.session, undefined);
|
||||
assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m);
|
||||
});
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { BrowserWindowConstructorOptions, Session } from 'electron';
|
||||
import * as path from 'path';
|
||||
import type { OverlayWindowKind } from './overlay-window-input';
|
||||
|
||||
export function buildOverlayWindowOptions(
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
isDev: boolean;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindowConstructorOptions {
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
|
||||
return {
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
webSecurity: true,
|
||||
session: options.yomitanSession ?? undefined,
|
||||
additionalArguments: [`--overlay-layer=${kind}`],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, type Session } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
@@ -79,10 +78,33 @@ export function createOverlayWindow(
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindow {
|
||||
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
webSecurity: true,
|
||||
additionalArguments: [`--overlay-layer=${kind}`],
|
||||
},
|
||||
});
|
||||
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
loadOverlayWindowLayer(window, kind);
|
||||
@@ -148,5 +170,4 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
|
||||
loadOverlayWindowLayer(window, layer);
|
||||
}
|
||||
|
||||
export { buildOverlayWindowOptions } from './overlay-window-options';
|
||||
export type { OverlayWindowKind } from './overlay-window-input';
|
||||
|
||||
@@ -185,7 +185,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
return;
|
||||
@@ -195,7 +194,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
import { mergeTokens } from '../../token-merger';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
@@ -33,7 +33,6 @@ type MecabTokenEnrichmentFn = (
|
||||
|
||||
export interface TokenizerServiceDeps {
|
||||
getYomitanExt: () => Extension | null;
|
||||
getYomitanSession?: () => Session | null;
|
||||
getYomitanParserWindow: () => BrowserWindow | null;
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||
@@ -64,7 +63,6 @@ interface MecabTokenizerLike {
|
||||
|
||||
export interface TokenizerDepsRuntimeOptions {
|
||||
getYomitanExt: () => Extension | null;
|
||||
getYomitanSession?: () => Session | null;
|
||||
getYomitanParserWindow: () => BrowserWindow | null;
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||
@@ -184,7 +182,6 @@ export function createTokenizerDepsRuntime(
|
||||
|
||||
return {
|
||||
getYomitanExt: options.getYomitanExt,
|
||||
getYomitanSession: options.getYomitanSession,
|
||||
getYomitanParserWindow: options.getYomitanParserWindow,
|
||||
setYomitanParserWindow: options.setYomitanParserWindow,
|
||||
getYomitanParserReadyPromise: options.getYomitanParserReadyPromise,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
@@ -10,7 +10,6 @@ interface LoggerLike {
|
||||
|
||||
interface YomitanParserRuntimeDeps {
|
||||
getYomitanExt: () => Extension | null;
|
||||
getYomitanSession?: () => Session | null;
|
||||
getYomitanParserWindow: () => BrowserWindow | null;
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||
@@ -466,7 +465,6 @@ async function ensureYomitanParserWindow(
|
||||
|
||||
const initPromise = (async () => {
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const parserWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
@@ -474,7 +472,7 @@ async function ensureYomitanParserWindow(
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
session: yomitanSession,
|
||||
session: session.defaultSession,
|
||||
},
|
||||
});
|
||||
deps.setYomitanParserWindow(parserWindow);
|
||||
@@ -541,7 +539,6 @@ async function createYomitanExtensionWindow(
|
||||
}
|
||||
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
@@ -549,7 +546,7 @@ async function createYomitanExtensionWindow(
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
session: yomitanSession,
|
||||
session: session.defaultSession,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { ensureExtensionCopy } from './yomitan-extension-copy';
|
||||
import {
|
||||
getYomitanExtensionSearchPaths,
|
||||
resolveExternalYomitanExtensionPath,
|
||||
resolveExistingYomitanExtensionPath,
|
||||
} from './yomitan-extension-paths';
|
||||
import {
|
||||
clearYomitanExtensionRuntimeState,
|
||||
clearYomitanParserRuntimeState,
|
||||
} from './yomitan-extension-runtime-state';
|
||||
|
||||
const { session } = electron;
|
||||
const logger = createLogger('main:yomitan-extension-loader');
|
||||
@@ -20,82 +14,51 @@ const logger = createLogger('main:yomitan-extension-loader');
|
||||
export interface YomitanExtensionLoaderDeps {
|
||||
userDataPath: string;
|
||||
extensionPath?: string;
|
||||
externalProfilePath?: string;
|
||||
getYomitanParserWindow: () => BrowserWindow | null;
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
setYomitanExtension: (extension: Extension | null) => void;
|
||||
setYomitanSession: (session: Session | null) => void;
|
||||
}
|
||||
|
||||
export async function loadYomitanExtension(
|
||||
deps: YomitanExtensionLoaderDeps,
|
||||
): Promise<Extension | null> {
|
||||
const clearRuntimeState = () =>
|
||||
clearYomitanExtensionRuntimeState({
|
||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||
setYomitanExtension: () => deps.setYomitanExtension(null),
|
||||
setYomitanSession: () => deps.setYomitanSession(null),
|
||||
});
|
||||
const clearParserState = () =>
|
||||
clearYomitanParserRuntimeState({
|
||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||
});
|
||||
const externalProfilePath = deps.externalProfilePath?.trim() ?? '';
|
||||
let extPath: string | null = null;
|
||||
let targetSession: Session = session.defaultSession;
|
||||
const searchPaths = getYomitanExtensionSearchPaths({
|
||||
explicitPath: deps.extensionPath,
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync);
|
||||
|
||||
if (externalProfilePath) {
|
||||
const resolvedProfilePath = path.resolve(externalProfilePath);
|
||||
extPath = resolveExternalYomitanExtensionPath(resolvedProfilePath, fs.existsSync);
|
||||
if (!extPath) {
|
||||
logger.error('External Yomitan extension not found in configured profile path');
|
||||
logger.error('Expected unpacked extension at:', path.join(resolvedProfilePath, 'extensions'));
|
||||
clearRuntimeState();
|
||||
return null;
|
||||
}
|
||||
|
||||
targetSession = session.fromPath(resolvedProfilePath);
|
||||
} else {
|
||||
const searchPaths = getYomitanExtensionSearchPaths({
|
||||
explicitPath: deps.extensionPath,
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync);
|
||||
|
||||
if (!extPath) {
|
||||
logger.error('Yomitan extension not found in any search path');
|
||||
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
|
||||
clearRuntimeState();
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||
if (extensionCopy.copied) {
|
||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
}
|
||||
extPath = extensionCopy.targetDir;
|
||||
if (!extPath) {
|
||||
logger.error('Yomitan extension not found in any search path');
|
||||
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
|
||||
return null;
|
||||
}
|
||||
|
||||
clearParserState();
|
||||
deps.setYomitanSession(targetSession);
|
||||
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||
if (extensionCopy.copied) {
|
||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
}
|
||||
extPath = extensionCopy.targetDir;
|
||||
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (parserWindow && !parserWindow.isDestroyed()) {
|
||||
parserWindow.destroy();
|
||||
}
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
|
||||
try {
|
||||
const extensions = targetSession.extensions;
|
||||
const extensions = session.defaultSession.extensions;
|
||||
const extension = extensions
|
||||
? await extensions.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
})
|
||||
: await targetSession.loadExtension(extPath, {
|
||||
: await session.defaultSession.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
deps.setYomitanExtension(extension);
|
||||
@@ -103,7 +66,7 @@ export async function loadYomitanExtension(
|
||||
} catch (err) {
|
||||
logger.error('Failed to load Yomitan extension:', (err as Error).message);
|
||||
logger.error('Full error:', err);
|
||||
clearRuntimeState();
|
||||
deps.setYomitanExtension(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
getYomitanExtensionSearchPaths,
|
||||
resolveExternalYomitanExtensionPath,
|
||||
resolveExistingYomitanExtensionPath,
|
||||
} from './yomitan-extension-paths';
|
||||
|
||||
@@ -52,19 +51,3 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => {
|
||||
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
|
||||
const resolved = resolveExternalYomitanExtensionPath(profilePath, (candidate) =>
|
||||
candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
|
||||
);
|
||||
|
||||
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
|
||||
});
|
||||
|
||||
test('resolveExternalYomitanExtensionPath returns null when external profile has no extension', () => {
|
||||
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
|
||||
const resolved = resolveExternalYomitanExtensionPath(profilePath, () => false);
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
@@ -58,16 +58,3 @@ export function resolveYomitanExtensionPath(
|
||||
): string | null {
|
||||
return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync);
|
||||
}
|
||||
|
||||
export function resolveExternalYomitanExtensionPath(
|
||||
externalProfilePath: string,
|
||||
existsSync: (path: string) => boolean = fs.existsSync,
|
||||
): string | null {
|
||||
const normalizedProfilePath = externalProfilePath.trim();
|
||||
if (!normalizedProfilePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
||||
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
|
||||
|
||||
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
|
||||
const calls: string[] = [];
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => {
|
||||
calls.push('destroy');
|
||||
},
|
||||
};
|
||||
|
||||
clearYomitanParserRuntimeState({
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
||||
setYomitanParserReadyPromise: (promise) =>
|
||||
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
||||
setYomitanParserInitPromise: (promise) =>
|
||||
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']);
|
||||
});
|
||||
|
||||
test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => {
|
||||
const calls: string[] = [];
|
||||
const parserWindow = {
|
||||
isDestroyed: () => true,
|
||||
destroy: () => {
|
||||
calls.push('destroy');
|
||||
},
|
||||
};
|
||||
|
||||
clearYomitanParserRuntimeState({
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
||||
setYomitanParserReadyPromise: (promise) =>
|
||||
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
||||
setYomitanParserInitPromise: (promise) =>
|
||||
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
type ParserWindowLike = {
|
||||
isDestroyed?: () => boolean;
|
||||
destroy?: () => void;
|
||||
} | null;
|
||||
|
||||
export interface YomitanParserRuntimeStateDeps {
|
||||
getYomitanParserWindow: () => ParserWindowLike;
|
||||
setYomitanParserWindow: (window: null) => void;
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
}
|
||||
|
||||
export interface YomitanExtensionRuntimeStateDeps extends YomitanParserRuntimeStateDeps {
|
||||
setYomitanExtension: (extension: null) => void;
|
||||
setYomitanSession: (session: null) => void;
|
||||
}
|
||||
|
||||
export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDeps): void {
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (parserWindow && !parserWindow.isDestroyed?.()) {
|
||||
parserWindow.destroy?.();
|
||||
}
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
}
|
||||
|
||||
export function clearYomitanExtensionRuntimeState(
|
||||
deps: YomitanExtensionRuntimeStateDeps,
|
||||
): void {
|
||||
clearYomitanParserRuntimeState(deps);
|
||||
deps.setYomitanExtension(null);
|
||||
deps.setYomitanSession(null);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
|
||||
@@ -9,7 +9,6 @@ export interface OpenYomitanSettingsWindowOptions {
|
||||
yomitanExt: Extension | null;
|
||||
getExistingWindow: () => BrowserWindow | null;
|
||||
setWindow: (window: BrowserWindow | null) => void;
|
||||
yomitanSession?: Session | null;
|
||||
onWindowClosed?: () => void;
|
||||
}
|
||||
|
||||
@@ -38,7 +37,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
session: options.yomitanSession ?? session.defaultSession,
|
||||
session: session.defaultSession,
|
||||
},
|
||||
});
|
||||
options.setWindow(settingsWindow);
|
||||
|
||||
109
src/main.ts
109
src/main.ts
@@ -23,7 +23,6 @@ import {
|
||||
shell,
|
||||
protocol,
|
||||
Extension,
|
||||
Session,
|
||||
Menu,
|
||||
nativeImage,
|
||||
Tray,
|
||||
@@ -359,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,
|
||||
@@ -376,8 +376,6 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
@@ -693,7 +691,6 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
});
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
detectPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
@@ -1330,7 +1327,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
getConfig: () => {
|
||||
const config = getResolvedConfig().anilist.characterDictionary;
|
||||
return {
|
||||
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||
enabled: config.enabled,
|
||||
maxLoaded: config.maxLoaded,
|
||||
profileScope: config.profileScope,
|
||||
};
|
||||
@@ -1350,12 +1347,6 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
});
|
||||
},
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
yomitanProfilePolicy.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
@@ -1363,12 +1354,6 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
});
|
||||
},
|
||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
yomitanProfilePolicy.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
@@ -1376,12 +1361,6 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
});
|
||||
},
|
||||
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
yomitanProfilePolicy.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await upsertYomitanDictionarySettings(
|
||||
dictionaryTitle,
|
||||
@@ -1835,7 +1814,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
configReady: snapshot.configReady,
|
||||
dictionaryCount: snapshot.dictionaryCount,
|
||||
canFinish: snapshot.canFinish,
|
||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||
@@ -1859,9 +1837,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
openYomitanSettings();
|
||||
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.';
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'refresh') {
|
||||
@@ -2343,7 +2320,6 @@ const {
|
||||
appState.yomitanParserWindow = null;
|
||||
appState.yomitanParserReadyPromise = null;
|
||||
appState.yomitanParserInitPromise = null;
|
||||
appState.yomitanSession = null;
|
||||
},
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
flushMpvLog: () => flushPendingMpvLogWrites(),
|
||||
@@ -2761,9 +2737,6 @@ const {
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
|
||||
return;
|
||||
}
|
||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
@@ -2807,7 +2780,6 @@ const {
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: {
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
appState.yomitanParserWindow = window as BrowserWindow | null;
|
||||
@@ -2841,9 +2813,7 @@ const {
|
||||
'subtitle.annotation.jlpt',
|
||||
getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
),
|
||||
getCharacterDictionaryEnabled: () =>
|
||||
getResolvedConfig().anilist.characterDictionary.enabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||
getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled,
|
||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
@@ -3017,7 +2987,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
||||
|
||||
async function loadYomitanExtension(): Promise<Extension | null> {
|
||||
const extension = await yomitanExtensionRuntime.loadYomitanExtension();
|
||||
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
if (extension) {
|
||||
await syncYomitanDefaultProfileAnkiServer();
|
||||
}
|
||||
return extension;
|
||||
@@ -3025,7 +2995,7 @@ async function loadYomitanExtension(): Promise<Extension | null> {
|
||||
|
||||
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
||||
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||
if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
if (extension) {
|
||||
await syncYomitanDefaultProfileAnkiServer();
|
||||
}
|
||||
return extension;
|
||||
@@ -3040,7 +3010,6 @@ function getPreferredYomitanAnkiServerUrl(): string {
|
||||
function getYomitanParserRuntimeDeps() {
|
||||
return {
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||
appState.yomitanParserWindow = window;
|
||||
@@ -3057,10 +3026,6 @@ function getYomitanParserRuntimeDeps() {
|
||||
}
|
||||
|
||||
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
||||
return;
|
||||
@@ -3114,19 +3079,8 @@ function initializeOverlayRuntime(): void {
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
const message =
|
||||
'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
logger.warn(
|
||||
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
|
||||
);
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
showMpvOsd(message);
|
||||
return false;
|
||||
}
|
||||
function openYomitanSettings(): void {
|
||||
openYomitanSettingsHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -3454,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,
|
||||
@@ -3533,13 +3496,8 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: async (targetPath?: string) => {
|
||||
const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason();
|
||||
if (disabledReason) {
|
||||
throw new Error(disabledReason);
|
||||
}
|
||||
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
|
||||
},
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
@@ -3562,11 +3520,10 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
@@ -3627,15 +3584,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
},
|
||||
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
|
||||
});
|
||||
const yomitanProfilePolicy = createYomitanProfilePolicy({
|
||||
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
|
||||
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore,
|
||||
userDataPath: USER_DATA_PATH,
|
||||
externalProfilePath: configuredExternalYomitanProfilePath,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
appState.yomitanParserWindow = window as BrowserWindow | null;
|
||||
@@ -3649,9 +3600,6 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
setYomitanExtension: (extension) => {
|
||||
appState.yomitanExt = extension;
|
||||
},
|
||||
setYomitanSession: (nextSession) => {
|
||||
appState.yomitanSession = nextSession;
|
||||
},
|
||||
getYomitanExtension: () => appState.yomitanExt,
|
||||
getLoadInFlight: () => yomitanLoadInFlight,
|
||||
setLoadInFlight: (promise) => {
|
||||
@@ -3693,18 +3641,11 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
});
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
openYomitanSettingsWindow: ({
|
||||
yomitanExt,
|
||||
getExistingWindow,
|
||||
setWindow,
|
||||
yomitanSession,
|
||||
}) => {
|
||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => {
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: yomitanExt as Extension,
|
||||
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
||||
setWindow: (window) => setWindow(window as BrowserWindow | null),
|
||||
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
|
||||
onWindowClosed: () => {
|
||||
if (appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||
@@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
getMpvClient: params.getMpvClient,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
|
||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService(
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
export type { OverlayHostedModal };
|
||||
|
||||
@@ -68,19 +68,15 @@ test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentWindow: unknown = null;
|
||||
const extension = { id: 'ext' };
|
||||
const yomitanSession = { id: 'session' };
|
||||
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => extension,
|
||||
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
|
||||
calls.push(
|
||||
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
|
||||
),
|
||||
openYomitanSettingsWindow: ({ yomitanExt }) =>
|
||||
calls.push(`open:${(yomitanExt as { id: string }).id}`),
|
||||
getExistingWindow: () => currentWindow,
|
||||
setWindow: (window) => {
|
||||
currentWindow = window;
|
||||
calls.push('set-window');
|
||||
},
|
||||
getYomitanSession: () => yomitanSession,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
})();
|
||||
@@ -92,10 +88,9 @@ test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window) => deps.setWindow(window),
|
||||
yomitanSession: deps.getYomitanSession(),
|
||||
});
|
||||
deps.logWarn('warn');
|
||||
deps.logError('error', new Error('boom'));
|
||||
assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']);
|
||||
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
|
||||
assert.deepEqual(currentWindow, { id: 'win' });
|
||||
});
|
||||
|
||||
@@ -66,12 +66,10 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
||||
yomitanExt: TYomitanExt;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
setWindow: (window: TWindow | null) => void;
|
||||
yomitanSession?: unknown | null;
|
||||
onWindowClosed?: () => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
setWindow: (window: TWindow | null) => void;
|
||||
getYomitanSession?: () => unknown | null;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
@@ -81,12 +79,10 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
||||
yomitanExt: TYomitanExt;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
setWindow: (window: TWindow | null) => void;
|
||||
yomitanSession?: unknown | null;
|
||||
onWindowClosed?: () => void;
|
||||
}) => deps.openYomitanSettingsWindow(params),
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window: TWindow | null) => deps.setWindow(window),
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getCharacterDictionaryDisabledReason,
|
||||
isCharacterDictionaryRuntimeEnabled,
|
||||
} from './character-dictionary-availability';
|
||||
|
||||
test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => {
|
||||
assert.equal(isCharacterDictionaryRuntimeEnabled(''), true);
|
||||
assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true);
|
||||
assert.equal(getCharacterDictionaryDisabledReason(''), null);
|
||||
});
|
||||
|
||||
test('character dictionary runtime is disabled when external Yomitan profile is configured', () => {
|
||||
assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false);
|
||||
assert.equal(
|
||||
getCharacterDictionaryDisabledReason('/tmp/gsm-profile'),
|
||||
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
|
||||
);
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export function isCharacterDictionaryRuntimeEnabled(externalProfilePath: string): boolean {
|
||||
return externalProfilePath.trim().length === 0;
|
||||
}
|
||||
|
||||
export function getCharacterDictionaryDisabledReason(externalProfilePath: string): string | null {
|
||||
if (isCharacterDictionaryRuntimeEnabled(externalProfilePath)) {
|
||||
return null;
|
||||
}
|
||||
return 'Character dictionary is disabled while yomitan.externalProfilePath is configured.';
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -143,154 +143,6 @@ test('setup service requires explicit finish for incomplete installs and support
|
||||
const completed = await service.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.state.completionSource, 'user');
|
||||
assert.equal(completed.state.yomitanSetupMode, 'internal');
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const initial = await service.ensureSetupStateInitialized();
|
||||
assert.equal(initial.canFinish, true);
|
||||
|
||||
const completed = await service.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.state.yomitanSetupMode, 'external');
|
||||
assert.equal(completed.dictionaryCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service does not probe internal dictionaries when external yomitan is configured', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => {
|
||||
throw new Error('should not probe internal dictionaries in external mode');
|
||||
},
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'completed');
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
assert.equal(snapshot.externalYomitanConfigured, true);
|
||||
assert.equal(snapshot.dictionaryCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
await service.ensureSetupStateInitialized();
|
||||
await service.markSetupCompleted();
|
||||
|
||||
const relaunched = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await relaunched.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'incomplete');
|
||||
assert.equal(snapshot.state.yomitanSetupMode, null);
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
await service.ensureSetupStateInitialized();
|
||||
await service.markSetupCompleted();
|
||||
|
||||
const relaunched = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await relaunched.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'completed');
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface SetupStatusSnapshot {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||
@@ -140,50 +139,10 @@ function getEffectiveWindowsMpvShortcutPreferences(
|
||||
};
|
||||
}
|
||||
|
||||
function isYomitanSetupSatisfied(options: {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
}): boolean {
|
||||
if (!options.configReady) {
|
||||
return false;
|
||||
}
|
||||
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
|
||||
}
|
||||
|
||||
async function resolveYomitanSetupStatus(deps: {
|
||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
}): Promise<{
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
}> {
|
||||
const configReady =
|
||||
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
|
||||
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
|
||||
|
||||
if (configReady && externalYomitanConfigured) {
|
||||
return {
|
||||
configReady,
|
||||
dictionaryCount: 0,
|
||||
externalYomitanConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
configReady,
|
||||
dictionaryCount: await deps.getYomitanDictionaryCount(),
|
||||
externalYomitanConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirstRunSetupService(deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
configDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
installPlugin: () => Promise<PluginInstallResult>;
|
||||
detectWindowsMpvShortcuts?: () =>
|
||||
@@ -209,12 +168,7 @@ export function createFirstRunSetupService(deps: {
|
||||
};
|
||||
|
||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const dictionaryCount = await deps.getYomitanDictionaryCount();
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
const detectedWindowsMpvShortcuts = isWindows
|
||||
? await deps.detectWindowsMpvShortcuts?.()
|
||||
@@ -227,15 +181,12 @@ export function createFirstRunSetupService(deps: {
|
||||
state,
|
||||
installedWindowsMpvShortcuts,
|
||||
);
|
||||
const configReady =
|
||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
||||
return {
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
}),
|
||||
externalYomitanConfigured,
|
||||
canFinish: dictionaryCount >= 1,
|
||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||
windowsMpvShortcuts: {
|
||||
@@ -266,32 +217,20 @@ export function createFirstRunSetupService(deps: {
|
||||
return {
|
||||
ensureSetupStateInitialized: async () => {
|
||||
const state = readState();
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
!(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied)
|
||||
) {
|
||||
if (isSetupCompleted(state)) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
}
|
||||
|
||||
if (yomitanSetupSatisfied) {
|
||||
const dictionaryCount = await deps.getYomitanDictionaryCount();
|
||||
const configReady =
|
||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
||||
if (configReady && dictionaryCount >= 1) {
|
||||
const completedState = writeState({
|
||||
...state,
|
||||
status: 'completed',
|
||||
completedAt: new Date().toISOString(),
|
||||
completionSource: 'legacy_auto_detected',
|
||||
yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal',
|
||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||
});
|
||||
return buildSnapshot(completedState);
|
||||
@@ -303,7 +242,6 @@ export function createFirstRunSetupService(deps: {
|
||||
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||
}),
|
||||
);
|
||||
@@ -338,7 +276,6 @@ export function createFirstRunSetupService(deps: {
|
||||
status: 'completed',
|
||||
completedAt: new Date().toISOString(),
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
|
||||
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
configReady: true,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
@@ -39,7 +38,6 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcuts: {
|
||||
@@ -56,32 +54,6 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
assert.match(html, /Reinstall mpv plugin/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
dictionaryCount: 0,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: true,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: null,
|
||||
});
|
||||
|
||||
assert.match(html, /External profile configured/);
|
||||
assert.match(
|
||||
html,
|
||||
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||
action: 'refresh',
|
||||
@@ -145,7 +117,6 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
||||
configReady: false,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface FirstRunSetupHtmlModel {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: {
|
||||
@@ -115,23 +114,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const yomitanMeta = model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const yomitanBadgeLabel = model.externalYomitanConfigured
|
||||
? 'External'
|
||||
: model.dictionaryCount >= 1
|
||||
? 'Ready'
|
||||
: 'Missing';
|
||||
const yomitanBadgeTone = model.externalYomitanConfigured
|
||||
? 'ready'
|
||||
: model.dictionaryCount >= 1
|
||||
? 'ready'
|
||||
: 'warn';
|
||||
const footerMessage = model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -275,9 +257,12 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
<div class="meta">${escapeHtml(yomitanMeta)}</div>
|
||||
<div class="meta">${model.dictionaryCount} installed</div>
|
||||
</div>
|
||||
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||
${renderStatusBadge(
|
||||
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
|
||||
model.dictionaryCount >= 1 ? 'ready' : 'warn',
|
||||
)}
|
||||
</div>
|
||||
${windowsShortcutCard}
|
||||
<div class="actions">
|
||||
@@ -288,7 +273,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
||||
</div>
|
||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||
<div class="footer">${escapeHtml(footerMessage)}</div>
|
||||
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
||||
|
||||
export function createSetOverlayVisibleHandler(deps: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeOptionState } from '../../types';
|
||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
|
||||
type RuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
|
||||
test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const yomitanSession = { id: 'session' } as never;
|
||||
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
|
||||
createOverlayWindowCore: (kind) => ({ kind }),
|
||||
isDev: true,
|
||||
@@ -19,13 +18,11 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
});
|
||||
|
||||
const overlayDeps = buildOverlayDeps();
|
||||
assert.equal(overlayDeps.isDev, true);
|
||||
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
||||
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
|
||||
overlayDeps.forwardTabToMpv();
|
||||
|
||||
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Session } from 'electron';
|
||||
|
||||
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
createOverlayWindowCore: (
|
||||
kind: 'visible' | 'modal',
|
||||
@@ -12,7 +10,6 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
isDev: boolean;
|
||||
@@ -23,7 +20,6 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
return () => ({
|
||||
createOverlayWindowCore: deps.createOverlayWindowCore,
|
||||
@@ -35,7 +31,6 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
test('create overlay window handler forwards options and kind', () => {
|
||||
const calls: string[] = [];
|
||||
const window = { id: 1 };
|
||||
const yomitanSession = { id: 'session' } as never;
|
||||
const createOverlayWindow = createCreateOverlayWindowHandler({
|
||||
createOverlayWindowCore: (kind, options) => {
|
||||
calls.push(`kind:${kind}`);
|
||||
assert.equal(options.isDev, true);
|
||||
assert.equal(options.isOverlayVisible('visible'), true);
|
||||
assert.equal(options.isOverlayVisible('modal'), false);
|
||||
assert.equal(options.yomitanSession, yomitanSession);
|
||||
options.forwardTabToMpv();
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.setOverlayDebugVisualizationEnabled(true);
|
||||
@@ -31,7 +29,6 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
});
|
||||
|
||||
assert.equal(createOverlayWindow('visible'), window);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Session } from 'electron';
|
||||
|
||||
type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
@@ -14,7 +12,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
isDev: boolean;
|
||||
@@ -25,7 +22,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
return (kind: OverlayWindowKind): TWindow => {
|
||||
return deps.createOverlayWindowCore(kind, {
|
||||
@@ -37,7 +33,6 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,14 +7,10 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
||||
let modalWindow: { kind: string } | null = null;
|
||||
let debugEnabled = false;
|
||||
const calls: string[] = [];
|
||||
const yomitanSession = { id: 'session' } as never;
|
||||
|
||||
const runtime = createOverlayWindowRuntimeHandlers<{ kind: string }>({
|
||||
const runtime = createOverlayWindowRuntimeHandlers({
|
||||
createOverlayWindowDeps: {
|
||||
createOverlayWindowCore: (kind, options) => {
|
||||
assert.equal(options.yomitanSession, yomitanSession);
|
||||
return { kind };
|
||||
},
|
||||
createOverlayWindowCore: (kind) => ({ kind }),
|
||||
isDev: true,
|
||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
|
||||
@@ -25,7 +21,6 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
},
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
|
||||
@@ -23,7 +23,6 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
||||
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
return (): TokenizerDepsRuntimeOptions => ({
|
||||
getYomitanExt: () => deps.getYomitanExt(),
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
|
||||
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||
|
||||
@@ -13,31 +13,20 @@ test('load yomitan extension main deps builder maps callbacks', async () => {
|
||||
return null;
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => calls.push('set-window'),
|
||||
setYomitanParserReadyPromise: () => calls.push('set-ready'),
|
||||
setYomitanParserInitPromise: () => calls.push('set-init'),
|
||||
setYomitanExtension: () => calls.push('set-ext'),
|
||||
setYomitanSession: () => calls.push('set-session'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.userDataPath, '/tmp/subminer');
|
||||
assert.equal(deps.externalProfilePath, '/tmp/gsm-profile');
|
||||
await deps.loadYomitanExtensionCore({} as never);
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
deps.setYomitanExtension(null);
|
||||
deps.setYomitanSession(null as never);
|
||||
assert.deepEqual(calls, [
|
||||
'load-core',
|
||||
'set-window',
|
||||
'set-ready',
|
||||
'set-init',
|
||||
'set-ext',
|
||||
'set-session',
|
||||
]);
|
||||
assert.deepEqual(calls, ['load-core', 'set-window', 'set-ready', 'set-init', 'set-ext']);
|
||||
});
|
||||
|
||||
test('ensure yomitan extension loaded main deps builder maps callbacks', async () => {
|
||||
|
||||
@@ -12,13 +12,11 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan
|
||||
return (): LoadYomitanExtensionMainDeps => ({
|
||||
loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options),
|
||||
userDataPath: deps.userDataPath,
|
||||
externalProfilePath: deps.externalProfilePath,
|
||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
|
||||
setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise),
|
||||
setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise),
|
||||
setYomitanExtension: (extension) => deps.setYomitanExtension(extension),
|
||||
setYomitanSession: (session) => deps.setYomitanSession(session),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,35 +12,23 @@ test('load yomitan extension handler forwards parser state dependencies', async
|
||||
const loadYomitanExtension = createLoadYomitanExtensionHandler({
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
calls.push(`path:${options.userDataPath}`);
|
||||
calls.push(`external:${options.externalProfilePath ?? ''}`);
|
||||
assert.equal(options.getYomitanParserWindow(), parserWindow);
|
||||
options.setYomitanParserWindow(null);
|
||||
options.setYomitanParserReadyPromise(null);
|
||||
options.setYomitanParserInitPromise(null);
|
||||
options.setYomitanExtension(extension);
|
||||
options.setYomitanSession(null);
|
||||
return extension;
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
setYomitanParserWindow: () => calls.push('set-window'),
|
||||
setYomitanParserReadyPromise: () => calls.push('set-ready'),
|
||||
setYomitanParserInitPromise: () => calls.push('set-init'),
|
||||
setYomitanExtension: () => calls.push('set-ext'),
|
||||
setYomitanSession: () => calls.push('set-session'),
|
||||
});
|
||||
|
||||
assert.equal(await loadYomitanExtension(), extension);
|
||||
assert.deepEqual(calls, [
|
||||
'path:/tmp/subminer',
|
||||
'external:/tmp/gsm-profile',
|
||||
'set-window',
|
||||
'set-ready',
|
||||
'set-init',
|
||||
'set-ext',
|
||||
'set-session',
|
||||
]);
|
||||
assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']);
|
||||
});
|
||||
|
||||
test('ensure yomitan loader returns existing extension when available', async () => {
|
||||
|
||||
@@ -4,24 +4,20 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext
|
||||
export function createLoadYomitanExtensionHandler(deps: {
|
||||
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
|
||||
userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
|
||||
externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath'];
|
||||
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
|
||||
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
|
||||
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
|
||||
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
|
||||
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
|
||||
setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession'];
|
||||
}) {
|
||||
return async (): Promise<Extension | null> => {
|
||||
return deps.loadYomitanExtensionCore({
|
||||
userDataPath: deps.userDataPath,
|
||||
externalProfilePath: deps.externalProfilePath,
|
||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||
setYomitanExtension: deps.setYomitanExtension,
|
||||
setYomitanSession: deps.setYomitanSession,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
let parserWindow: unknown = null;
|
||||
let readyPromise: Promise<void> | null = null;
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
let yomitanSession: unknown = null;
|
||||
let receivedExternalProfilePath = '';
|
||||
let loadCalls = 0;
|
||||
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
|
||||
releaseLoad: null,
|
||||
@@ -19,11 +17,9 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
const runtime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
loadCalls += 1;
|
||||
receivedExternalProfilePath = options.externalProfilePath ?? '';
|
||||
options.setYomitanParserWindow(null);
|
||||
options.setYomitanParserReadyPromise(Promise.resolve());
|
||||
options.setYomitanParserInitPromise(Promise.resolve(true));
|
||||
options.setYomitanSession({ id: 'session' } as never);
|
||||
return await new Promise<Extension | null>((resolve) => {
|
||||
releaseLoadState.releaseLoad = (value) => {
|
||||
options.setYomitanExtension(value);
|
||||
@@ -32,7 +28,6 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
});
|
||||
},
|
||||
userDataPath: '/tmp',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: (window) => {
|
||||
parserWindow = window;
|
||||
@@ -46,9 +41,6 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
setYomitanExtension: (next) => {
|
||||
extension = next;
|
||||
},
|
||||
setYomitanSession: (next) => {
|
||||
yomitanSession = next;
|
||||
},
|
||||
getYomitanExtension: () => extension,
|
||||
getLoadInFlight: () => inFlight,
|
||||
setLoadInFlight: (promise) => {
|
||||
@@ -63,8 +55,6 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
assert.equal(parserWindow, null);
|
||||
assert.ok(readyPromise);
|
||||
assert.ok(initPromise);
|
||||
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||
|
||||
const fakeExtension = { id: 'yomitan' } as Extension;
|
||||
const releaseLoad = releaseLoadState.releaseLoad;
|
||||
@@ -84,26 +74,18 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
|
||||
|
||||
test('yomitan extension runtime direct load delegates to core', async () => {
|
||||
let loadCalls = 0;
|
||||
let receivedExternalProfilePath = '';
|
||||
let yomitanSession: unknown = null;
|
||||
|
||||
const runtime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
loadYomitanExtensionCore: async () => {
|
||||
loadCalls += 1;
|
||||
receivedExternalProfilePath = options.externalProfilePath ?? '';
|
||||
options.setYomitanSession({ id: 'session' } as never);
|
||||
return null;
|
||||
},
|
||||
userDataPath: '/tmp',
|
||||
externalProfilePath: '/tmp/gsm-profile',
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: () => {},
|
||||
setYomitanSession: (next) => {
|
||||
yomitanSession = next;
|
||||
},
|
||||
getYomitanExtension: () => null,
|
||||
getLoadInFlight: () => null,
|
||||
setLoadInFlight: () => {},
|
||||
@@ -111,6 +93,4 @@ test('yomitan extension runtime direct load delegates to core', async () => {
|
||||
|
||||
assert.equal(await runtime.loadYomitanExtension(), null);
|
||||
assert.equal(loadCalls, 1);
|
||||
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||
});
|
||||
|
||||
@@ -23,13 +23,11 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
|
||||
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
||||
loadYomitanExtensionCore: deps.loadYomitanExtensionCore,
|
||||
userDataPath: deps.userDataPath,
|
||||
externalProfilePath: deps.externalProfilePath,
|
||||
getYomitanParserWindow: deps.getYomitanParserWindow,
|
||||
setYomitanParserWindow: deps.setYomitanParserWindow,
|
||||
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
|
||||
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
|
||||
setYomitanExtension: deps.setYomitanExtension,
|
||||
setYomitanSession: deps.setYomitanSession,
|
||||
});
|
||||
const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler(
|
||||
buildLoadYomitanExtensionMainDepsHandler(),
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createYomitanProfilePolicy } from './yomitan-profile-policy';
|
||||
|
||||
test('yomitan profile policy trims external profile path and marks read-only mode', () => {
|
||||
const calls: string[] = [];
|
||||
const policy = createYomitanProfilePolicy({
|
||||
externalProfilePath: ' /tmp/gsm-profile ',
|
||||
logInfo: (message) => calls.push(message),
|
||||
});
|
||||
|
||||
assert.equal(policy.externalProfilePath, '/tmp/gsm-profile');
|
||||
assert.equal(policy.isExternalReadOnlyMode(), true);
|
||||
assert.equal(policy.isCharacterDictionaryEnabled(), false);
|
||||
assert.equal(
|
||||
policy.getCharacterDictionaryDisabledReason(),
|
||||
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
|
||||
);
|
||||
|
||||
policy.logSkippedWrite('importYomitanDictionary(sample.zip)');
|
||||
assert.deepEqual(calls, [
|
||||
'[yomitan] skipping importYomitanDictionary(sample.zip): yomitan.externalProfilePath is configured; external profile mode is read-only',
|
||||
]);
|
||||
});
|
||||
|
||||
test('yomitan profile policy keeps character dictionary enabled without external profile path', () => {
|
||||
const policy = createYomitanProfilePolicy({
|
||||
externalProfilePath: ' ',
|
||||
logInfo: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(policy.externalProfilePath, '');
|
||||
assert.equal(policy.isExternalReadOnlyMode(), false);
|
||||
assert.equal(policy.isCharacterDictionaryEnabled(), true);
|
||||
assert.equal(policy.getCharacterDictionaryDisabledReason(), null);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import {
|
||||
getCharacterDictionaryDisabledReason,
|
||||
isCharacterDictionaryRuntimeEnabled,
|
||||
} from './character-dictionary-availability';
|
||||
|
||||
export function createYomitanProfilePolicy(options: {
|
||||
externalProfilePath: string;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
const externalProfilePath = options.externalProfilePath.trim();
|
||||
|
||||
return {
|
||||
externalProfilePath,
|
||||
isExternalReadOnlyMode: (): boolean => externalProfilePath.length > 0,
|
||||
isCharacterDictionaryEnabled: (): boolean =>
|
||||
isCharacterDictionaryRuntimeEnabled(externalProfilePath),
|
||||
getCharacterDictionaryDisabledReason: (): string | null =>
|
||||
getCharacterDictionaryDisabledReason(externalProfilePath),
|
||||
logSkippedWrite: (action: string): void => {
|
||||
options.logInfo(
|
||||
`[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { formatSkippedYomitanWriteAction } from './yomitan-read-only-log';
|
||||
|
||||
test('formatSkippedYomitanWriteAction redacts full filesystem paths to basenames', () => {
|
||||
assert.equal(
|
||||
formatSkippedYomitanWriteAction('importYomitanDictionary', '/tmp/private/merged.zip'),
|
||||
'importYomitanDictionary(merged.zip)',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatSkippedYomitanWriteAction redacts dictionary titles', () => {
|
||||
assert.equal(
|
||||
formatSkippedYomitanWriteAction('deleteYomitanDictionary', 'SubMiner Character Dictionary'),
|
||||
'deleteYomitanDictionary(<redacted>)',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatSkippedYomitanWriteAction falls back when value is blank', () => {
|
||||
assert.equal(
|
||||
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', ' '),
|
||||
'upsertYomitanDictionarySettings(<redacted>)',
|
||||
);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as path from 'path';
|
||||
|
||||
function redactSkippedYomitanWriteValue(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return '<redacted>';
|
||||
}
|
||||
|
||||
if (actionName === 'importYomitanDictionary') {
|
||||
const basename = path.basename(trimmed);
|
||||
return basename || '<redacted>';
|
||||
}
|
||||
|
||||
return '<redacted>';
|
||||
}
|
||||
|
||||
export function formatSkippedYomitanWriteAction(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
||||
}
|
||||
@@ -22,16 +22,14 @@ test('yomitan opener warns when extension cannot be loaded', async () => {
|
||||
});
|
||||
|
||||
test('yomitan opener opens settings window when extension is available', async () => {
|
||||
let forwardedSession: { id: string } | null | undefined;
|
||||
const yomitanSession = { id: 'session' };
|
||||
let opened = false;
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
|
||||
forwardedSession = nextSession as { id: string } | null;
|
||||
openYomitanSettingsWindow: () => {
|
||||
opened = true;
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
getYomitanSession: () => yomitanSession,
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
@@ -39,5 +37,5 @@ test('yomitan opener opens settings window when extension is available', async (
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(forwardedSession, yomitanSession);
|
||||
assert.equal(opened, true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
type YomitanExtensionLike = unknown;
|
||||
type BrowserWindowLike = unknown;
|
||||
type SessionLike = unknown;
|
||||
|
||||
export function createOpenYomitanSettingsHandler(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
||||
@@ -8,12 +7,10 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
yomitanExt: YomitanExtensionLike;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
yomitanSession?: SessionLike | null;
|
||||
onWindowClosed?: () => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
getYomitanSession?: () => SessionLike | null;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
@@ -24,16 +21,10 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||
if (!yomitanSession) {
|
||||
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
|
||||
return;
|
||||
}
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: deps.getExistingWindow,
|
||||
setWindow: deps.setWindow,
|
||||
yomitanSession,
|
||||
});
|
||||
})().catch((error) => {
|
||||
deps.logError('Failed to open Yomitan settings window.', error);
|
||||
|
||||
@@ -5,12 +5,11 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime';
|
||||
test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
let existingWindow: { id: string } | null = null;
|
||||
const calls: string[] = [];
|
||||
const yomitanSession = { id: 'session' };
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
|
||||
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => {
|
||||
calls.push('open-window');
|
||||
const current = getExistingWindow();
|
||||
if (!current) {
|
||||
setWindow({ id: 'settings' });
|
||||
@@ -20,7 +19,6 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
setWindow: (window) => {
|
||||
existingWindow = window as { id: string } | null;
|
||||
},
|
||||
getYomitanSession: () => yomitanSession,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
@@ -29,30 +27,5 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(existingWindow, { id: 'settings' });
|
||||
assert.deepEqual(calls, ['open-window:session']);
|
||||
});
|
||||
|
||||
test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => {
|
||||
let existingWindow: { id: string } | null = null;
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: () => {
|
||||
calls.push('open-window');
|
||||
},
|
||||
getExistingWindow: () => existingWindow as never,
|
||||
setWindow: (window) => {
|
||||
existingWindow = window as { id: string } | null;
|
||||
},
|
||||
getYomitanSession: () => null,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
runtime.openYomitanSettings();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(existingWindow, null);
|
||||
assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']);
|
||||
assert.deepEqual(calls, ['open-window']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
|
||||
import type {
|
||||
Keybinding,
|
||||
@@ -143,7 +143,6 @@ export function transitionAnilistUpdateInFlightState(
|
||||
|
||||
export interface AppState {
|
||||
yomitanExt: Extension | null;
|
||||
yomitanSession: Session | null;
|
||||
yomitanSettingsWindow: BrowserWindow | null;
|
||||
yomitanParserWindow: BrowserWindow | null;
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
@@ -220,7 +219,6 @@ export interface StartupState {
|
||||
export function createAppState(values: AppStateInitialValues): AppState {
|
||||
return {
|
||||
yomitanExt: null,
|
||||
yomitanSession: null,
|
||||
yomitanSettingsWindow: null,
|
||||
yomitanParserWindow: null,
|
||||
anilistSetupWindow: null,
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
|
||||
|
||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
notifyOverlayModalClosed: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
notifyOverlayModalOpened: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||
},
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
|
||||
107
src/renderer/controller-status-indicator.test.ts
Normal file
107
src/renderer/controller-status-indicator.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
|
||||
let nextTimerId = 1;
|
||||
const scheduled = new Map<number, () => void>();
|
||||
const classList = createClassList(['hidden']);
|
||||
const toast = {
|
||||
textContent: '',
|
||||
classList,
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
durationMs: 1500,
|
||||
setTimeout: (callback: () => void) => {
|
||||
const id = nextTimerId++;
|
||||
scheduled.set(id, callback);
|
||||
return id as never;
|
||||
},
|
||||
clearTimeout: (id) => {
|
||||
scheduled.delete(id as never as number);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
});
|
||||
|
||||
assert.equal(classList.contains('hidden'), true);
|
||||
assert.equal(toast.textContent, '');
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(classList.contains('hidden'), false);
|
||||
assert.match(toast.textContent, /controller detected/i);
|
||||
assert.match(toast.textContent, /pad-1/i);
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
const [hide] = scheduled.values();
|
||||
hide?.();
|
||||
|
||||
assert.equal(classList.contains('hidden'), true);
|
||||
assert.equal(toast.textContent, '');
|
||||
});
|
||||
|
||||
test('controller status indicator announces newly detected controllers after startup', () => {
|
||||
const toast = {
|
||||
textContent: '',
|
||||
classList: createClassList(['hidden']),
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
toast.classList.add('hidden');
|
||||
toast.textContent = '';
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
],
|
||||
activeGamepadId: 'pad-1',
|
||||
});
|
||||
|
||||
assert.equal(toast.classList.contains('hidden'), false);
|
||||
assert.match(toast.textContent, /pad-2/i);
|
||||
});
|
||||
69
src/renderer/controller-status-indicator.ts
Normal file
69
src/renderer/controller-status-indicator.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ControllerDeviceInfo } from '../types';
|
||||
|
||||
type ControllerSnapshot = {
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
};
|
||||
|
||||
type ControllerStatusIndicatorOptions = {
|
||||
durationMs?: number;
|
||||
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
|
||||
};
|
||||
|
||||
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
|
||||
if (!device) return 'Controller';
|
||||
return device.id || `Gamepad ${device.index}`;
|
||||
}
|
||||
|
||||
export function createControllerStatusIndicator(
|
||||
dom: {
|
||||
controllerStatusToast: {
|
||||
textContent: string;
|
||||
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
|
||||
};
|
||||
},
|
||||
options: ControllerStatusIndicatorOptions = {},
|
||||
) {
|
||||
const durationMs = options.durationMs ?? 2200;
|
||||
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
|
||||
const cancelTimeout =
|
||||
options.clearTimeout ??
|
||||
((timer: ReturnType<typeof setTimeout> | number) =>
|
||||
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
|
||||
let previousConnectedIds = new Set<string>();
|
||||
|
||||
function show(message: string): void {
|
||||
if (hideTimeout !== null) {
|
||||
cancelTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
dom.controllerStatusToast.textContent = message;
|
||||
dom.controllerStatusToast.classList.remove('hidden');
|
||||
hideTimeout = scheduleTimeout(() => {
|
||||
dom.controllerStatusToast.classList.add('hidden');
|
||||
dom.controllerStatusToast.textContent = '';
|
||||
hideTimeout = null;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
function update(snapshot: ControllerSnapshot): void {
|
||||
const newDevices = snapshot.connectedGamepads.filter(
|
||||
(device) => !previousConnectedIds.has(device.id),
|
||||
);
|
||||
if (newDevices.length > 0) {
|
||||
const activeDevice = snapshot.connectedGamepads.find(
|
||||
(device) => device.id === snapshot.activeGamepadId,
|
||||
);
|
||||
const announcedDevice =
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
|
||||
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
||||
}
|
||||
|
||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||
}
|
||||
|
||||
return { update };
|
||||
}
|
||||
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import { createGamepadController } from './gamepad-controller.js';
|
||||
|
||||
type TestGamepad = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
axes: number[];
|
||||
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
||||
};
|
||||
|
||||
function createGamepad(
|
||||
id: string,
|
||||
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
|
||||
): TestGamepad {
|
||||
return {
|
||||
id,
|
||||
index: options.index ?? 0,
|
||||
connected: true,
|
||||
mapping: 'standard',
|
||||
axes: options.axes ?? [0, 0, 0, 0],
|
||||
buttons:
|
||||
options.buttons ??
|
||||
Array.from({ length: 16 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createControllerConfig(
|
||||
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
|
||||
bindings?: Partial<ResolvedControllerConfig['bindings']>;
|
||||
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
||||
} = {},
|
||||
): ResolvedControllerConfig {
|
||||
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
|
||||
overrides;
|
||||
return {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
...(buttonIndexOverrides ?? {}),
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
...(bindingOverrides ?? {}),
|
||||
},
|
||||
...restOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('gamepad controller selects the first connected controller by default', () => {
|
||||
const updates: string[] = [];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: (state) => {
|
||||
updates.push(state.activeGamepadId ?? 'none');
|
||||
},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||
assert.deepEqual(updates.at(-1), 'pad-2');
|
||||
});
|
||||
|
||||
test('gamepad controller prefers saved controller id when connected', () => {
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
|
||||
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||
});
|
||||
|
||||
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||
toggleLookup: () => calls.push('toggle-lookup'),
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-keyboard-mode']);
|
||||
});
|
||||
|
||||
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig({ enabled: false }),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
|
||||
const calls: string[] = [];
|
||||
const selectionCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
let axes = [0.9, 0, 0, 0];
|
||||
let keyboardModeEnabled = true;
|
||||
let interactionBlocked = true;
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => interactionBlocked,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => calls.push('toggle-lookup'),
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
interactionBlocked = false;
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(selectionCalls, []);
|
||||
|
||||
buttons[0] = { value: 0, pressed: false, touched: false };
|
||||
axes = [0, 0, 0, 0];
|
||||
controller.poll(200);
|
||||
|
||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||
axes = [0.9, 0, 0, 0];
|
||||
controller.poll(300);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-lookup']);
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
||||
const calls: number[] = [];
|
||||
let axes = [0.9, 0, 0, 0];
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => calls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
controller.poll(260);
|
||||
|
||||
assert.deepEqual(calls, [1]);
|
||||
|
||||
controller.poll(340);
|
||||
|
||||
assert.deepEqual(calls, [1, 1]);
|
||||
|
||||
axes = [0, 0, 0, 0];
|
||||
controller.poll(360);
|
||||
axes = [-0.9, 0, 0, 0];
|
||||
controller.poll(380);
|
||||
|
||||
assert.deepEqual(calls, [1, 1, -1]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
||||
const calls: string[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[8] = { value: 1, pressed: true, touched: true };
|
||||
buttons[4] = { value: 1, pressed: true, touched: true };
|
||||
buttons[5] = { value: 1, pressed: true, touched: true };
|
||||
buttons[6] = { value: 0.8, pressed: true, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () =>
|
||||
[
|
||||
createGamepad('pad-1', {
|
||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||
buttons,
|
||||
}),
|
||||
],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
previousAudio: 'none',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => calls.push('prev-audio'),
|
||||
nextAudio: () => calls.push('next-audio'),
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.equal(calls.includes('next-audio'), true);
|
||||
assert.equal(calls.includes('play-audio'), true);
|
||||
assert.equal(calls.includes('prev-audio'), false);
|
||||
assert.equal(calls.includes('toggle-mpv-pause'), true);
|
||||
assert.equal(calls.includes('quit-mpv'), true);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
|
||||
assert.equal(calls.includes('jump:160'), true);
|
||||
});
|
||||
|
||||
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['quit-mpv']);
|
||||
});
|
||||
|
||||
test('gamepad controller honors configured raw button index overrides', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[11] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
buttonIndices: {
|
||||
select: 11,
|
||||
},
|
||||
bindings: { quitMpv: 'select' },
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => calls.push('quit-mpv'),
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['quit-mpv']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
|
||||
const calls: string[] = [];
|
||||
let axes = [0, 0, 0.85, 0, 0];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
axes = [0, 0, 0.85, 0, -0.85];
|
||||
controller.poll(200);
|
||||
|
||||
assert.deepEqual(calls, ['jump:-160']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
|
||||
const selectionCalls: number[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[15] = { value: 1, pressed: false, touched: true };
|
||||
buttons[12] = { value: 1, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
||||
const selectionCalls: number[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: (delta) => selectionCalls.push(delta),
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.7, pressed: false, touched: true };
|
||||
buttons[7] = { value: 0.8, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'analog',
|
||||
triggerDeadzone: 0.6,
|
||||
bindings: {
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger digital mode uses pressed state only', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.9, pressed: true, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'digital',
|
||||
triggerDeadzone: 1,
|
||||
bindings: {
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[9] = { value: 1, pressed: true, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
playCurrentAudio: 'none',
|
||||
},
|
||||
}),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => true,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => {},
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => calls.push('play-audio'),
|
||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-mpv-pause']);
|
||||
});
|
||||
571
src/renderer/handlers/gamepad-controller.ts
Normal file
571
src/renderer/handlers/gamepad-controller.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerButtonBinding,
|
||||
ControllerDeviceInfo,
|
||||
ControllerRuntimeSnapshot,
|
||||
ControllerTriggerInputMode,
|
||||
ResolvedControllerConfig,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
pressed?: boolean;
|
||||
touched?: boolean;
|
||||
};
|
||||
|
||||
type GamepadLike = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
|
||||
type GamepadControllerOptions = {
|
||||
getGamepads: () => Array<GamepadLike | null>;
|
||||
getConfig: () => ResolvedControllerConfig;
|
||||
getKeyboardModeEnabled: () => boolean;
|
||||
getLookupWindowOpen: () => boolean;
|
||||
getInteractionBlocked: () => boolean;
|
||||
toggleKeyboardMode: () => void;
|
||||
toggleLookup: () => void;
|
||||
closeLookup: () => void;
|
||||
moveSelection: (delta: -1 | 1) => void;
|
||||
mineCard: () => void;
|
||||
quitMpv: () => void;
|
||||
previousAudio: () => void;
|
||||
nextAudio: () => void;
|
||||
playCurrentAudio: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
scrollPopup: (deltaPixels: number) => void;
|
||||
jumpPopup: (deltaPixels: number) => void;
|
||||
onState: (state: ControllerRuntimeSnapshot) => void;
|
||||
};
|
||||
|
||||
type HoldState = {
|
||||
repeatStarted: boolean;
|
||||
direction: -1 | 1 | null;
|
||||
lastFireAt: number;
|
||||
initialFired: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, number> = {
|
||||
select: 8,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
};
|
||||
|
||||
const AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const DPAD_BUTTON_INDEX = {
|
||||
up: 12,
|
||||
down: 13,
|
||||
left: 14,
|
||||
right: 15,
|
||||
} as const;
|
||||
const DPAD_AXIS_INDEX = {
|
||||
horizontal: 6,
|
||||
vertical: 7,
|
||||
} as const;
|
||||
|
||||
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
|
||||
return binding === 'leftTrigger' || binding === 'rightTrigger';
|
||||
}
|
||||
|
||||
function resolveButtonIndex(
|
||||
config: ResolvedControllerConfig,
|
||||
binding: ControllerButtonBinding,
|
||||
): number {
|
||||
if (binding === 'none') {
|
||||
return -1;
|
||||
}
|
||||
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
|
||||
}
|
||||
|
||||
function normalizeButtonState(
|
||||
gamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
binding: ControllerButtonBinding,
|
||||
triggerInputMode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (binding === 'none') {
|
||||
return false;
|
||||
}
|
||||
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
|
||||
if (isTriggerBinding(binding)) {
|
||||
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
|
||||
}
|
||||
return normalizeRawButtonState(button, triggerDeadzone);
|
||||
}
|
||||
|
||||
function normalizeRawButtonState(
|
||||
button: ControllerButtonState | undefined,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function normalizeTriggerState(
|
||||
button: ControllerButtonState | undefined,
|
||||
mode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
if (mode === 'digital') {
|
||||
return Boolean(button.pressed);
|
||||
}
|
||||
if (mode === 'analog') {
|
||||
return button.value >= triggerDeadzone;
|
||||
}
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
|
||||
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
|
||||
}
|
||||
|
||||
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
||||
const value = gamepad.axes[axisIndex];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function resolveDpadValue(
|
||||
gamepad: GamepadLike,
|
||||
negativeIndex: number,
|
||||
positiveIndex: number,
|
||||
triggerDeadzone: number,
|
||||
): number {
|
||||
const negative = gamepad.buttons[negativeIndex];
|
||||
const positive = gamepad.buttons[positiveIndex];
|
||||
return (
|
||||
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
|
||||
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadAxisValue(
|
||||
gamepad: GamepadLike,
|
||||
axisIndex: number,
|
||||
): number {
|
||||
const value = resolveGamepadAxis(gamepad, axisIndex);
|
||||
if (Math.abs(value) < 0.5) {
|
||||
return 0;
|
||||
}
|
||||
return Math.sign(value);
|
||||
}
|
||||
|
||||
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
|
||||
return gamepads
|
||||
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
|
||||
.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
function createHoldState(): HoldState {
|
||||
return {
|
||||
repeatStarted: false,
|
||||
direction: null,
|
||||
lastFireAt: 0,
|
||||
initialFired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
|
||||
if (!state.initialFired) {
|
||||
state.initialFired = true;
|
||||
state.lastFireAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
const elapsed = now - state.lastFireAt;
|
||||
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
|
||||
if (elapsed < threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.repeatStarted = true;
|
||||
state.lastFireAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetHeldAction(state: HoldState): void {
|
||||
state.repeatStarted = false;
|
||||
state.direction = null;
|
||||
state.lastFireAt = 0;
|
||||
state.initialFired = false;
|
||||
}
|
||||
|
||||
function syncHeldActionBlocked(
|
||||
state: HoldState,
|
||||
value: number,
|
||||
now: number,
|
||||
activationThreshold: number,
|
||||
): void {
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
state.repeatStarted = false;
|
||||
state.direction = direction;
|
||||
state.lastFireAt = now;
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
let jumpHold = createHoldState();
|
||||
let activeGamepadId: string | null = null;
|
||||
let lastPollAt: number | null = null;
|
||||
|
||||
function getConnectedGamepads(): GamepadLike[] {
|
||||
return resolveConnectedGamepads(options.getGamepads());
|
||||
}
|
||||
|
||||
function resolveActiveGamepad(
|
||||
gamepads: GamepadLike[],
|
||||
config: ResolvedControllerConfig,
|
||||
): GamepadLike | null {
|
||||
if (gamepads.length === 0) return null;
|
||||
if (config.preferredGamepadId.trim().length > 0) {
|
||||
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
}
|
||||
return gamepads[0] ?? null;
|
||||
}
|
||||
|
||||
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
|
||||
activeGamepadId = activeGamepad?.id ?? null;
|
||||
options.onState({
|
||||
connectedGamepads: gamepads.map((gamepad) => ({
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
mapping: gamepad.mapping,
|
||||
connected: gamepad.connected,
|
||||
})) satisfies ControllerDeviceInfo[],
|
||||
activeGamepadId,
|
||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||
rawButtons: activeGamepad?.buttons
|
||||
? activeGamepad.buttons.map((button) => ({
|
||||
value: button.value,
|
||||
pressed: Boolean(button.pressed),
|
||||
touched: button.touched,
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonEdge(
|
||||
binding: ControllerButtonBinding,
|
||||
isPressed: boolean,
|
||||
action: () => void,
|
||||
): void {
|
||||
if (binding === 'none') {
|
||||
return;
|
||||
}
|
||||
const wasPressed = previousButtons.get(binding) ?? false;
|
||||
previousButtons.set(binding, isPressed);
|
||||
if (!wasPressed && isPressed) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(selectionHold);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
if (selectionHold.direction !== direction) {
|
||||
resetHeldAction(selectionHold);
|
||||
selectionHold.direction = direction;
|
||||
}
|
||||
|
||||
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||
options.moveSelection(direction);
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(jumpHold);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = value > 0 ? 1 : -1;
|
||||
if (jumpHold.direction !== direction) {
|
||||
resetHeldAction(jumpHold);
|
||||
jumpHold.direction = direction;
|
||||
}
|
||||
|
||||
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||
options.jumpPopup(direction * config.horizontalJumpPixels);
|
||||
}
|
||||
}
|
||||
|
||||
function syncBlockedInteractionState(
|
||||
activeGamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
now: number,
|
||||
): void {
|
||||
const buttonBindings = new Set<ControllerButtonBinding>([
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.bindings.toggleLookup,
|
||||
config.bindings.closeLookup,
|
||||
config.bindings.mineCard,
|
||||
config.bindings.quitMpv,
|
||||
config.bindings.previousAudio,
|
||||
config.bindings.nextAudio,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.bindings.toggleMpvPause,
|
||||
]);
|
||||
|
||||
for (const binding of buttonBindings) {
|
||||
if (binding === 'none') continue;
|
||||
previousButtons.set(
|
||||
binding,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
binding,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const selectionValue = (() => {
|
||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||
})();
|
||||
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
syncHeldActionBlocked(
|
||||
jumpHold,
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
now,
|
||||
Math.max(config.stickDeadzone, 0.55),
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
}
|
||||
|
||||
function poll(now: number): void {
|
||||
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
||||
lastPollAt = now;
|
||||
const config = options.getConfig();
|
||||
const connectedGamepads = getConnectedGamepads();
|
||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||
publishState(connectedGamepads, activeGamepad);
|
||||
|
||||
if (!activeGamepad) {
|
||||
previousButtons = new Map();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
lastPollAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionAllowed =
|
||||
config.enabled &&
|
||||
options.getKeyboardModeEnabled() &&
|
||||
!options.getInteractionBlocked();
|
||||
if (config.enabled) {
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleKeyboardMode,
|
||||
);
|
||||
}
|
||||
if (!interactionAllowed) {
|
||||
syncBlockedInteractionState(activeGamepad, config, now);
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.closeLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.closeLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.closeLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.mineCard,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.mineCard,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.mineCard,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.quitMpv,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.quitMpv,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.quitMpv,
|
||||
);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
handleButtonEdge(
|
||||
config.bindings.previousAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.previousAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.previousAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.nextAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.nextAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.nextAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
config.bindings.playCurrentAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.playCurrentAudio,
|
||||
);
|
||||
|
||||
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
|
||||
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
|
||||
if (elapsedMs > 0) {
|
||||
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
if (dpadVertical !== 0) {
|
||||
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
config.bindings.toggleMpvPause,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleMpvPause,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
(() => {
|
||||
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||
})(),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
poll,
|
||||
getActiveGamepadId: (): string | null => activeGamepadId,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||
import {
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
type?: string;
|
||||
@@ -11,6 +14,9 @@ type CommandEventDetail = {
|
||||
key?: string;
|
||||
code?: string;
|
||||
repeat?: boolean;
|
||||
direction?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
};
|
||||
|
||||
function createClassList() {
|
||||
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
|
||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
|
||||
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
|
||||
};
|
||||
|
||||
const selection = {
|
||||
removeAllRanges: () => {},
|
||||
addRange: () => {},
|
||||
removeAllRanges: () => {
|
||||
selectionClearCount += 1;
|
||||
},
|
||||
addRange: () => {
|
||||
selectionAddCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: () => {},
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||
const listeners = windowListeners.get(type) ?? [];
|
||||
listeners.push(listener);
|
||||
windowListeners.set(type, listeners);
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||
commandEvents.push(detail ?? {});
|
||||
}
|
||||
const listeners = windowListeners.get(event.type) ?? [];
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
@@ -192,6 +213,13 @@ function installKeyboardTestGlobals() {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchWindowEvent(type: string): void {
|
||||
const listeners = windowListeners.get(type) ?? [];
|
||||
for (const listener of listeners) {
|
||||
listener(new Event(type));
|
||||
}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
|
||||
windowFocusCalls: () => windowFocusCalls,
|
||||
dispatchKeydown,
|
||||
dispatchFocusInOnPopup,
|
||||
dispatchWindowEvent,
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
},
|
||||
selectionClearCount: () => selectionClearCount,
|
||||
selectionAddCount: () => selectionAddCount,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
|
||||
function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
let controllerSelectOpenCount = 0;
|
||||
let controllerDebugOpenCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
|
||||
handleSubsyncKeydown: () => false,
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleControllerSelectKeydown: () => {
|
||||
controllerSelectKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
handleControllerDebugKeydown: () => false,
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectOpenCount += 1;
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugOpenCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
assert.equal(handlers.playCurrentAudioForController(), true);
|
||||
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
|
||||
assert.equal(handlers.scrollPopupByController(48, -24), true);
|
||||
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.slice(-3),
|
||||
[
|
||||
{ type: 'playCurrentAudio' },
|
||||
{ type: 'cycleAudioSource', direction: 1 },
|
||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
||||
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
|
||||
testGlobals.dispatchKeydown({
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
assert.equal(controllerDebugOpenCount(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
assert.equal(controllerSelectKeydownCount(), 1);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some(
|
||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: turning mode off clears selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(false);
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
||||
assert.equal(testGlobals.selectionAddCount() > 0, true);
|
||||
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.closeLookupWindow();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(false);
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||
assert.equal(testGlobals.selectionClearCount() > 0, true);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
const closeCommands = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
);
|
||||
assert.deepEqual(closeCommands.slice(-2), [
|
||||
{ type: 'setVisible', visible: false },
|
||||
{ type: 'clearActiveTextSource' },
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
const closeCommands = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
);
|
||||
assert.deepEqual(closeCommands.slice(-2), [
|
||||
{ type: 'setVisible', visible: false },
|
||||
{ type: 'clearActiveTextSource' },
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(0);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(0);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
assert.equal(handlers.moveSelectionForController(1), true);
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
assert.equal(handlers.moveSelectionForController(-1), true);
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(3);
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
handlers.handleSubtitleContentUpdated();
|
||||
setWordCount(4);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
|
||||
}) => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
openControllerSelectModal: () => void;
|
||||
openControllerDebugModal: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
@@ -30,6 +34,7 @@ export function createKeyboardHandlers(
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -105,6 +110,39 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'cycleAudioSource',
|
||||
direction,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupPlayCurrentAudio() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'playCurrentAudio',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'scrollBy',
|
||||
deltaX,
|
||||
deltaY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanFrontendScanSelectedText() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
@@ -115,6 +153,16 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchYomitanFrontendClearActiveTextSource() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'clearActiveTextSource',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey || e.metaKey;
|
||||
}
|
||||
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
|
||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||
}
|
||||
|
||||
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function getSubtitleWordNodes(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||
);
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||
for (const wordNode of wordNodes) {
|
||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
function clearNativeSubtitleSelection(): void {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
clearKeyboardSelectedWordClasses(wordNodes);
|
||||
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
ctx.state.keyboardSelectionVisible = false;
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
ctx.state.keyboardSelectedWordIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
const shouldRefreshLookup =
|
||||
pendingLookupRefreshAfterSubtitleSeek &&
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||
@@ -165,23 +231,32 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
if (resetSelectionToStartOnNextSubtitleSync) {
|
||||
ctx.state.keyboardSelectedWordIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
}
|
||||
|
||||
const selectedIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
);
|
||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (selectedWordNode) {
|
||||
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
const nextIndex = currentIndex + delta;
|
||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
return 'moved';
|
||||
}
|
||||
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (!selectedWordNode) return false;
|
||||
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
selectWordNodeText(selectedWordNode);
|
||||
|
||||
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
|
||||
toggleKeyboardDrivenMode();
|
||||
}
|
||||
|
||||
function handleSubtitleContentUpdated(): void {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
return;
|
||||
}
|
||||
resetSelectionToStartOnNextSubtitleSync = true;
|
||||
}
|
||||
|
||||
function handleLookupWindowToggleRequested(): void {
|
||||
if (ctx.state.yomitanPopupVisible) {
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
closeLookupWindow();
|
||||
return;
|
||||
}
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
function closeLookupWindow(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveSelectionForController(delta: -1 | 1): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const result = moveKeyboardSelection(delta);
|
||||
if (result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result === 'start-boundary' || result === 'end-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function forwardPopupKeydownForController(
|
||||
key: string,
|
||||
code: string,
|
||||
repeat: boolean = true,
|
||||
): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mineSelectedFromController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupMineSelected();
|
||||
return true;
|
||||
}
|
||||
|
||||
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupCycleAudioSource(direction);
|
||||
return true;
|
||||
}
|
||||
|
||||
function playCurrentAudioForController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupPlayCurrentAudio();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreOverlayKeyboardFocus(): void {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
window.focus();
|
||||
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
|
||||
const key = e.code;
|
||||
if (key === 'ArrowLeft') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
return true;
|
||||
}
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
if (result === 'end-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
if (result === 'end-boundary' || result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
clearNativeSubtitleSelection();
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
syncKeyboardTokenSelection();
|
||||
return;
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.controllerSelectModalOpen) {
|
||||
options.handleControllerSelectKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.controllerDebugModalOpen) {
|
||||
options.handleControllerDebugKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.sessionHelpModalOpen) {
|
||||
options.handleSessionHelpKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControllerModalShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
options.openControllerDebugModal();
|
||||
} else {
|
||||
options.openControllerSelectModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
|
||||
setupMpvInputForwarding,
|
||||
updateKeybindings,
|
||||
syncKeyboardTokenSelection,
|
||||
handleSubtitleContentUpdated,
|
||||
handleKeyboardModeToggleRequested,
|
||||
handleLookupWindowToggleRequested,
|
||||
closeLookupWindow,
|
||||
moveSelectionForController,
|
||||
forwardPopupKeydownForController,
|
||||
mineSelectedFromController,
|
||||
cyclePopupAudioSourceForController,
|
||||
playCurrentAudioForController,
|
||||
scrollPopupByController,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
<body>
|
||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||
<div id="overlay" tabindex="-1">
|
||||
<div
|
||||
id="controllerStatusToast"
|
||||
class="controller-status-toast hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div
|
||||
id="overlayErrorToast"
|
||||
class="overlay-error-toast hidden"
|
||||
@@ -192,6 +198,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Controller Selection</div>
|
||||
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="controllerSelectHint" class="runtime-options-hint">
|
||||
Arrow keys: select controller · Enter: save · Esc: close
|
||||
</div>
|
||||
<ul id="controllerSelectList" class="runtime-options-list"></ul>
|
||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||
<div class="subsync-footer">
|
||||
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
|
||||
Save Controller
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content runtime-modal-content controller-debug-content">
|
||||
<div
|
||||
id="controllerDebugToast"
|
||||
class="controller-debug-toast hidden"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Controller Debug</div>
|
||||
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="controllerDebugStatus" class="runtime-options-status"></div>
|
||||
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
|
||||
<div class="controller-debug-grid">
|
||||
<div>
|
||||
<div class="jimaku-section-title">Axes</div>
|
||||
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="jimaku-section-title">Buttons</div>
|
||||
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
<div class="controller-debug-span">
|
||||
<div class="controller-debug-section-header">
|
||||
<div class="jimaku-section-title">Config</div>
|
||||
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content session-help-content">
|
||||
<div class="modal-header">
|
||||
|
||||
237
src/renderer/modals/controller-debug.test.ts
Normal file
237
src/renderer/modals/controller-debug.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerDebugModal } from './controller-debug.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
state.controllerRawAxes = [0.5, -0.25];
|
||||
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerDebugModal();
|
||||
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
|
||||
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
|
||||
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
|
||||
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
|
||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller debug modal copies buttonIndices config to clipboard', async () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
window?: unknown;
|
||||
navigator?: unknown;
|
||||
};
|
||||
const previousWindow = globals.window;
|
||||
const previousNavigator = globals.navigator;
|
||||
const copied: string[] = [];
|
||||
const handlers: { copy: null | (() => void) } = { copy: null };
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText: async (text: string) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugCopy: {
|
||||
addEventListener: (_event: string, handler: () => void) => {
|
||||
handlers.copy = handler;
|
||||
},
|
||||
},
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerDebugModal();
|
||||
if (handlers.copy) {
|
||||
handlers.copy();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
|
||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
|
||||
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
|
||||
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
192
src/renderer/modals/controller-debug.ts
Normal file
192
src/renderer/modals/controller-debug.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function formatAxes(values: number[]): string {
|
||||
if (values.length === 0) return 'No controller axes available.';
|
||||
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
|
||||
}
|
||||
|
||||
function formatButtons(
|
||||
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
|
||||
): string {
|
||||
if (values.length === 0) return 'No controller buttons available.';
|
||||
return values
|
||||
.map(
|
||||
(button, index) =>
|
||||
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatButtonIndices(
|
||||
value:
|
||||
| {
|
||||
select: number;
|
||||
buttonSouth: number;
|
||||
buttonEast: number;
|
||||
buttonNorth: number;
|
||||
buttonWest: number;
|
||||
leftShoulder: number;
|
||||
rightShoulder: number;
|
||||
leftStickPress: number;
|
||||
rightStickPress: number;
|
||||
leftTrigger: number;
|
||||
rightTrigger: number;
|
||||
}
|
||||
| null,
|
||||
): string {
|
||||
if (!value) {
|
||||
return 'No controller config loaded.';
|
||||
}
|
||||
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API unavailable.');
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export function createControllerDebugModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function setStatus(message: string, isError: boolean = false): void {
|
||||
ctx.dom.controllerDebugStatus.textContent = message;
|
||||
if (isError) {
|
||||
ctx.dom.controllerDebugStatus.classList.add('error');
|
||||
} else {
|
||||
ctx.dom.controllerDebugStatus.classList.remove('error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearToastTimer(): void {
|
||||
if (toastTimer === null) return;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = null;
|
||||
}
|
||||
|
||||
function hideToast(): void {
|
||||
clearToastTimer();
|
||||
ctx.dom.controllerDebugToast.classList.add('hidden');
|
||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||
}
|
||||
|
||||
function showToast(message: string, isError: boolean = false): void {
|
||||
clearToastTimer();
|
||||
ctx.dom.controllerDebugToast.textContent = message;
|
||||
ctx.dom.controllerDebugToast.classList.remove('hidden');
|
||||
if (isError) {
|
||||
ctx.dom.controllerDebugToast.classList.add('error');
|
||||
} else {
|
||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||
}
|
||||
toastTimer = setTimeout(() => {
|
||||
hideToast();
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const activeDevice = ctx.state.connectedGamepads.find(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
);
|
||||
setStatus(
|
||||
activeDevice?.id ??
|
||||
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
|
||||
);
|
||||
ctx.dom.controllerDebugSummary.textContent =
|
||||
ctx.state.connectedGamepads.length > 0
|
||||
? ctx.state.connectedGamepads
|
||||
.map((device) => {
|
||||
const tags = [
|
||||
`#${device.index}`,
|
||||
device.mapping,
|
||||
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
||||
].filter(Boolean);
|
||||
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
|
||||
})
|
||||
.join('\n')
|
||||
: 'Connect a controller and press any button to populate raw input values.';
|
||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async function copyButtonIndicesToClipboard(): Promise<void> {
|
||||
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
|
||||
if (text.length === 0 || text === 'No controller config loaded.') {
|
||||
setStatus('No buttonIndices config available to copy.', true);
|
||||
showToast('No buttonIndices config available to copy.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeTextToClipboard(text);
|
||||
setStatus('Copied controller buttonIndices config.');
|
||||
showToast('Copied controller buttonIndices config.');
|
||||
} catch {
|
||||
setStatus('Failed to copy controller buttonIndices config.', true);
|
||||
showToast('Failed to copy controller buttonIndices config.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function openControllerDebugModal(): void {
|
||||
ctx.state.controllerDebugModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerDebugModal.classList.remove('hidden');
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||
hideToast();
|
||||
render();
|
||||
}
|
||||
|
||||
function closeControllerDebugModal(): void {
|
||||
if (!ctx.state.controllerDebugModalOpen) return;
|
||||
ctx.state.controllerDebugModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerDebugModal.classList.add('hidden');
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
|
||||
hideToast();
|
||||
window.electronAPI.notifyOverlayModalClosed('controller-debug');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeControllerDebugModal();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateSnapshot(): void {
|
||||
if (!ctx.state.controllerDebugModalOpen) return;
|
||||
render();
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.controllerDebugClose.addEventListener('click', () => {
|
||||
closeControllerDebugModal();
|
||||
});
|
||||
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
|
||||
void copyButtonIndicesToClipboard();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openControllerDebugModal,
|
||||
closeControllerDebugModal,
|
||||
handleControllerDebugKeydown,
|
||||
updateSnapshot,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
727
src/renderer/modals/controller-select.test.ts
Normal file
727
src/renderer/modals/controller-select.test.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerSelectModal } from './controller-select.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === undefined) {
|
||||
if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
return tokens.has(entry);
|
||||
}
|
||||
if (force) tokens.add(entry);
|
||||
else tokens.delete(entry);
|
||||
return force;
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller select modal saves the selected preferred controller', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async (update: {
|
||||
preferredGamepadId: string;
|
||||
preferredGamepadLabel: string;
|
||||
}) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const overlayClassList = createClassList();
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-2',
|
||||
preferredGamepadLabel: 'pad-2',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'leftShoulder',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'rightTrigger',
|
||||
toggleMpvPause: 'leftTrigger',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
];
|
||||
state.activeGamepadId = 'pad-2';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList, focus: () => {} },
|
||||
controllerSelectModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerSelectClose: { addEventListener: () => {} },
|
||||
controllerSelectHint: { textContent: '' },
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectList: {
|
||||
innerHTML: '',
|
||||
appendChild: () => {},
|
||||
},
|
||||
controllerSelectSave: { addEventListener: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerSelectModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
|
||||
await modal.handleControllerSelectKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(saved, [
|
||||
{
|
||||
preferredGamepadId: 'pad-2',
|
||||
preferredGamepadLabel: 'pad-2',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal preserves manual selection while controller polling updates', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'pad-1',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(), focus: () => {} },
|
||||
controllerSelectModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerSelectClose: { addEventListener: () => {} },
|
||||
controllerSelectHint: { textContent: '' },
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectList: {
|
||||
innerHTML: '',
|
||||
appendChild: () => {},
|
||||
},
|
||||
controllerSelectSave: { addEventListener: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerSelectModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 0);
|
||||
|
||||
modal.handleControllerSelectKeydown({
|
||||
key: 'ArrowDown',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
|
||||
modal.updateDevices();
|
||||
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal prefers active controller over saved preferred controller', () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'pad-1',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
];
|
||||
state.activeGamepadId = 'pad-2';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(), focus: () => {} },
|
||||
controllerSelectModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerSelectClose: { addEventListener: () => {} },
|
||||
controllerSelectHint: { textContent: '' },
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectList: {
|
||||
innerHTML: '',
|
||||
appendChild: () => {},
|
||||
},
|
||||
controllerSelectSave: { addEventListener: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerSelectModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal preserves saved status across polling updates', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'pad-1',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(), focus: () => {} },
|
||||
controllerSelectModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerSelectClose: { addEventListener: () => {} },
|
||||
controllerSelectHint: { textContent: '' },
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectList: {
|
||||
innerHTML: '',
|
||||
appendChild: () => {},
|
||||
},
|
||||
controllerSelectSave: { addEventListener: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerSelectModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
await modal.handleControllerSelectKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
modal.updateDevices();
|
||||
|
||||
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal surfaces save errors without mutating saved preference', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async () => {
|
||||
throw new Error('disk write failed');
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'pad-1',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-2';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(), focus: () => {} },
|
||||
controllerSelectModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerSelectClose: { addEventListener: () => {} },
|
||||
controllerSelectHint: { textContent: '' },
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectList: {
|
||||
innerHTML: '',
|
||||
appendChild: () => {},
|
||||
},
|
||||
controllerSelectSave: { addEventListener: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerSelectModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
await modal.handleControllerSelectKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
|
||||
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
let appendCount = 0;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'pad-1',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 220,
|
||||
repeatIntervalMs: 80,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||
];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(), focus: () => {} },
|
||||
controllerSelectModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerSelectClose: { addEventListener: () => {} },
|
||||
controllerSelectHint: { textContent: '' },
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectList: {
|
||||
innerHTML: '',
|
||||
appendChild: () => {
|
||||
appendCount += 1;
|
||||
},
|
||||
},
|
||||
controllerSelectSave: { addEventListener: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createControllerSelectModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerSelectModal();
|
||||
const initialAppendCount = appendCount;
|
||||
|
||||
modal.updateDevices();
|
||||
|
||||
assert.equal(appendCount, initialAppendCount);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
264
src/renderer/modals/controller-select.ts
Normal file
264
src/renderer/modals/controller-select.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
function clampSelectedIndex(ctx: RendererContext): void {
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
||||
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
|
||||
ctx.state.connectedGamepads.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function createControllerSelectModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let selectedControllerId: string | null = null;
|
||||
let lastRenderedDevicesKey = '';
|
||||
let lastRenderedActiveGamepadId: string | null = null;
|
||||
let lastRenderedPreferredId = '';
|
||||
|
||||
function getDevicesKey(): string {
|
||||
return ctx.state.connectedGamepads
|
||||
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
|
||||
.join('||');
|
||||
}
|
||||
|
||||
function syncSelectedControllerId(): void {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
selectedControllerId = selected?.id ?? null;
|
||||
}
|
||||
|
||||
function syncSelectedIndexToCurrentController(): void {
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
);
|
||||
if (activeIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = activeIndex;
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
|
||||
if (preferredIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
clampSelectedIndex(ctx);
|
||||
syncSelectedControllerId();
|
||||
}
|
||||
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.dom.controllerSelectStatus.textContent = message;
|
||||
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
ctx.dom.controllerSelectList.innerHTML = '';
|
||||
clampSelectedIndex(ctx);
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
ctx.state.connectedGamepads.forEach((device, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'runtime-options-list-entry';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'runtime-options-item runtime-options-item-button';
|
||||
button.classList.toggle('active', index === ctx.state.controllerDeviceSelectedIndex);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'runtime-options-label';
|
||||
label.textContent = device.id || `Gamepad ${device.index}`;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'runtime-options-value';
|
||||
const tags = [
|
||||
`Index ${device.index}`,
|
||||
device.mapping || 'unknown mapping',
|
||||
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
||||
device.id === preferredId ? 'saved' : null,
|
||||
].filter(Boolean);
|
||||
meta.textContent = tags.join(' · ');
|
||||
|
||||
button.appendChild(label);
|
||||
button.appendChild(meta);
|
||||
button.addEventListener('click', () => {
|
||||
ctx.state.controllerDeviceSelectedIndex = index;
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
});
|
||||
button.addEventListener('dblclick', () => {
|
||||
ctx.state.controllerDeviceSelectedIndex = index;
|
||||
syncSelectedControllerId();
|
||||
void saveSelectedController();
|
||||
});
|
||||
li.appendChild(button);
|
||||
|
||||
ctx.dom.controllerSelectList.appendChild(li);
|
||||
});
|
||||
|
||||
lastRenderedDevicesKey = getDevicesKey();
|
||||
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
||||
lastRenderedPreferredId = preferredId;
|
||||
}
|
||||
|
||||
function updateDevices(): void {
|
||||
if (!ctx.state.controllerSelectModalOpen) return;
|
||||
if (selectedControllerId) {
|
||||
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === selectedControllerId,
|
||||
);
|
||||
if (preservedIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
||||
} else {
|
||||
syncSelectedIndexToCurrentController();
|
||||
}
|
||||
} else {
|
||||
syncSelectedIndexToCurrentController();
|
||||
}
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const shouldRender =
|
||||
getDevicesKey() !== lastRenderedDevicesKey ||
|
||||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
||||
preferredId !== lastRenderedPreferredId;
|
||||
if (shouldRender) {
|
||||
renderList();
|
||||
}
|
||||
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus('No controllers detected.');
|
||||
return;
|
||||
}
|
||||
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
|
||||
if (
|
||||
currentStatus !== 'No controller selected.' &&
|
||||
!currentStatus.startsWith('Saved preferred controller:')
|
||||
) {
|
||||
setStatus('Select a controller to save as preferred.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedController(): Promise<void> {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
if (!selected) {
|
||||
setStatus('No controller selected.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.saveControllerPreference({
|
||||
preferredGamepadId: selected.id,
|
||||
preferredGamepadLabel: selected.id,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save preferred controller: ${message}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.controllerConfig) {
|
||||
ctx.state.controllerConfig.preferredGamepadId = selected.id;
|
||||
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
|
||||
}
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): void {
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
syncSelectedIndexToCurrentController();
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
|
||||
window.focus();
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
renderList();
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus('No controllers detected.');
|
||||
} else {
|
||||
setStatus('Select a controller to save as preferred.');
|
||||
}
|
||||
}
|
||||
|
||||
function closeControllerSelectModal(): void {
|
||||
if (!ctx.state.controllerSelectModalOpen) return;
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('controller-select');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeControllerSelectModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
|
||||
event.preventDefault();
|
||||
if (ctx.state.connectedGamepads.length > 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
||||
ctx.state.connectedGamepads.length - 1,
|
||||
ctx.state.controllerDeviceSelectedIndex + 1,
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
|
||||
event.preventDefault();
|
||||
if (ctx.state.connectedGamepads.length > 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = Math.max(
|
||||
0,
|
||||
ctx.state.controllerDeviceSelectedIndex - 1,
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void saveSelectedController();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.controllerSelectClose.addEventListener('click', () => {
|
||||
closeControllerSelectModal();
|
||||
});
|
||||
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
||||
void saveSelectedController();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openControllerSelectModal,
|
||||
closeControllerSelectModal,
|
||||
handleControllerSelectKeydown,
|
||||
updateDevices,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,11 @@ import type {
|
||||
ConfigHotReloadPayload,
|
||||
} from '../types';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||
import { createMouseHandlers } from './handlers/mouse.js';
|
||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||
import { createControllerDebugModal } from './modals/controller-debug.js';
|
||||
import { createControllerSelectModal } from './modals/controller-select.js';
|
||||
import { createJimakuModal } from './modals/jimaku.js';
|
||||
import { createKikuModal } from './modals/kiku.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
@@ -36,6 +40,7 @@ import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { createRendererState } from './state.js';
|
||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||
import {
|
||||
createRendererRecoveryController,
|
||||
registerRendererGlobalErrorHandlers,
|
||||
@@ -55,6 +60,8 @@ const ctx = {
|
||||
|
||||
function isAnySettingsModalOpen(): boolean {
|
||||
return (
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
|
||||
|
||||
function isAnyModalOpen(): boolean {
|
||||
return (
|
||||
ctx.state.controllerSelectModalOpen ||
|
||||
ctx.state.controllerDebugModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
},
|
||||
});
|
||||
const mouseHandlers = createMouseHandlers(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
||||
let lastSubtitlePreview = '';
|
||||
let lastSecondarySubtitlePreview = '';
|
||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controllerAnimationFrameId: number | null = null;
|
||||
|
||||
function truncateForErrorLog(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
||||
}
|
||||
|
||||
function getActiveModal(): string | null {
|
||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
|
||||
}
|
||||
|
||||
function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.controllerSelectModalOpen) {
|
||||
controllerSelectModal.closeControllerSelectModal();
|
||||
}
|
||||
if (ctx.state.controllerDebugModalOpen) {
|
||||
controllerDebugModal.closeControllerDebugModal();
|
||||
}
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
jimakuModal.closeJimakuModal();
|
||||
}
|
||||
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
|
||||
function applyControllerSnapshot(snapshot: {
|
||||
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
|
||||
activeGamepadId: string | null;
|
||||
rawAxes: number[];
|
||||
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
|
||||
}): void {
|
||||
controllerStatusIndicator.update({
|
||||
connectedGamepads: snapshot.connectedGamepads,
|
||||
activeGamepadId: snapshot.activeGamepadId,
|
||||
});
|
||||
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
||||
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
||||
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||
controllerSelectModal.updateDevices();
|
||||
controllerDebugModal.updateSnapshot();
|
||||
}
|
||||
|
||||
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||
}
|
||||
|
||||
function emitControllerPopupJump(deltaPixels: number): void {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
|
||||
}
|
||||
|
||||
function startControllerPolling(): void {
|
||||
if (controllerAnimationFrameId !== null) {
|
||||
cancelAnimationFrame(controllerAnimationFrameId);
|
||||
controllerAnimationFrameId = null;
|
||||
}
|
||||
|
||||
const gamepadController = createGamepadController({
|
||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||
getConfig: () =>
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: 'buttonSouth',
|
||||
closeLookup: 'buttonEast',
|
||||
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||
mineCard: 'buttonWest',
|
||||
quitMpv: 'select',
|
||||
previousAudio: 'none',
|
||||
nextAudio: 'rightShoulder',
|
||||
playCurrentAudio: 'leftShoulder',
|
||||
toggleMpvPause: 'leftStickPress',
|
||||
leftStickHorizontal: 'leftStickX',
|
||||
leftStickVertical: 'leftStickY',
|
||||
rightStickHorizontal: 'rightStickX',
|
||||
rightStickVertical: 'rightStickY',
|
||||
},
|
||||
},
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||
getInteractionBlocked: () => isAnyModalOpen(),
|
||||
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
||||
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
||||
closeLookup: () => {
|
||||
keyboardHandlers.closeLookupWindow();
|
||||
},
|
||||
moveSelection: (delta) => {
|
||||
keyboardHandlers.moveSelectionForController(delta);
|
||||
},
|
||||
mineCard: () => {
|
||||
keyboardHandlers.mineSelectedFromController();
|
||||
},
|
||||
quitMpv: () => {
|
||||
window.electronAPI.sendMpvCommand(['quit']);
|
||||
},
|
||||
previousAudio: () => {
|
||||
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
||||
},
|
||||
nextAudio: () => {
|
||||
keyboardHandlers.cyclePopupAudioSourceForController(1);
|
||||
},
|
||||
playCurrentAudio: () => {
|
||||
keyboardHandlers.playCurrentAudioForController();
|
||||
},
|
||||
toggleMpvPause: () => {
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
},
|
||||
scrollPopup: (deltaPixels) => {
|
||||
emitControllerPopupScroll(deltaPixels);
|
||||
},
|
||||
jumpPopup: (deltaPixels) => {
|
||||
emitControllerPopupJump(deltaPixels);
|
||||
},
|
||||
onState: (snapshot) => {
|
||||
applyControllerSnapshot(snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
const poll = (now: number): void => {
|
||||
gamepadController.poll(now);
|
||||
controllerAnimationFrameId = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
controllerAnimationFrameId = requestAnimationFrame(poll);
|
||||
}
|
||||
|
||||
function restoreOverlayInteractionAfterError(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
|
||||
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||
}
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||
measurementReporter.schedule();
|
||||
|
||||
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
|
||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
|
||||
mouseHandlers.setupDragging();
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
try {
|
||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||
} catch (error) {
|
||||
console.error('Failed to load controller config.', error);
|
||||
ctx.state.controllerConfig = null;
|
||||
}
|
||||
startControllerPolling();
|
||||
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {
|
||||
ControllerButtonSnapshot,
|
||||
ControllerDeviceInfo,
|
||||
ResolvedControllerConfig,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
@@ -53,6 +56,15 @@ export type RendererState = {
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
controllerSelectModalOpen: boolean;
|
||||
controllerDebugModalOpen: boolean;
|
||||
controllerDeviceSelectedIndex: number;
|
||||
controllerConfig: ResolvedControllerConfig | null;
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
controllerRawAxes: number[];
|
||||
controllerRawButtons: ControllerButtonSnapshot[];
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
|
||||
@@ -82,6 +94,7 @@ export type RendererState = {
|
||||
chordPending: boolean;
|
||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||
keyboardDrivenModeEnabled: boolean;
|
||||
keyboardSelectionVisible: boolean;
|
||||
keyboardSelectedWordIndex: number | null;
|
||||
yomitanPopupVisible: boolean;
|
||||
};
|
||||
@@ -122,6 +135,15 @@ export function createRendererState(): RendererState {
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
controllerDeviceSelectedIndex: 0,
|
||||
controllerConfig: null,
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
controllerRawAxes: [],
|
||||
controllerRawButtons: [],
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
|
||||
@@ -151,6 +173,7 @@ export function createRendererState(): RendererState {
|
||||
chordPending: false,
|
||||
chordTimeout: null,
|
||||
keyboardDrivenModeEnabled: false,
|
||||
keyboardSelectionVisible: false,
|
||||
keyboardSelectedWordIndex: null,
|
||||
yomitanPopupVisible: false,
|
||||
};
|
||||
|
||||
@@ -55,6 +55,34 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.controller-status-toast {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(138, 213, 202, 0.45);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
||||
color: rgba(228, 255, 251, 0.98);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.controller-status-toast:not(.hidden) {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.overlay-error-toast {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
@@ -321,6 +349,12 @@ body.settings-modal-open #subtitleContainer {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.settings-modal-open iframe.yomitan-popup,
|
||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .c {
|
||||
display: inline;
|
||||
position: relative;
|
||||
@@ -1013,6 +1047,10 @@ iframe[id^='yomitan-popup'] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.runtime-options-list-entry {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.runtime-options-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1022,7 +1060,15 @@ iframe[id^='yomitan-popup'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-options-item:last-child {
|
||||
.runtime-options-item-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.runtime-options-list-entry:last-child .runtime-options-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -1030,6 +1076,11 @@ iframe[id^='yomitan-popup'] {
|
||||
background: rgba(100, 180, 255, 0.15);
|
||||
}
|
||||
|
||||
.runtime-options-item-button:focus-visible {
|
||||
outline: 2px solid rgba(100, 180, 255, 0.85);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.runtime-options-label {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
@@ -1055,12 +1106,84 @@ iframe[id^='yomitan-popup'] {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.controller-debug-content {
|
||||
position: relative;
|
||||
width: min(760px, 94%);
|
||||
}
|
||||
|
||||
.controller-debug-toast {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 56px;
|
||||
z-index: 2;
|
||||
max-width: min(320px, calc(100% - 88px));
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(120, 214, 168, 0.34);
|
||||
background: rgba(20, 38, 30, 0.96);
|
||||
color: rgba(220, 255, 232, 0.98);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.controller-debug-toast.error {
|
||||
border-color: rgba(255, 143, 143, 0.34);
|
||||
background: rgba(52, 22, 24, 0.96);
|
||||
color: rgba(255, 225, 225, 0.98);
|
||||
}
|
||||
|
||||
.controller-debug-summary {
|
||||
min-height: 18px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.controller-debug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controller-debug-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.controller-debug-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.controller-debug-pre {
|
||||
min-height: 220px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.session-help-content {
|
||||
width: min(760px, 92%);
|
||||
max-height: 84%;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.controller-debug-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.session-help-shortcut,
|
||||
.session-help-warning,
|
||||
.session-help-status {
|
||||
|
||||
@@ -2,6 +2,7 @@ export type RendererDom = {
|
||||
subtitleRoot: HTMLElement;
|
||||
subtitleContainer: HTMLElement;
|
||||
overlay: HTMLElement;
|
||||
controllerStatusToast: HTMLDivElement;
|
||||
overlayErrorToast: HTMLDivElement;
|
||||
secondarySubContainer: HTMLElement;
|
||||
secondarySubRoot: HTMLElement;
|
||||
@@ -56,6 +57,23 @@ export type RendererDom = {
|
||||
subsyncRunButton: HTMLButtonElement;
|
||||
subsyncStatus: HTMLDivElement;
|
||||
|
||||
controllerSelectModal: HTMLDivElement;
|
||||
controllerSelectClose: HTMLButtonElement;
|
||||
controllerSelectHint: HTMLDivElement;
|
||||
controllerSelectStatus: HTMLDivElement;
|
||||
controllerSelectList: HTMLUListElement;
|
||||
controllerSelectSave: HTMLButtonElement;
|
||||
|
||||
controllerDebugModal: HTMLDivElement;
|
||||
controllerDebugClose: HTMLButtonElement;
|
||||
controllerDebugCopy: HTMLButtonElement;
|
||||
controllerDebugToast: HTMLDivElement;
|
||||
controllerDebugStatus: HTMLDivElement;
|
||||
controllerDebugSummary: HTMLDivElement;
|
||||
controllerDebugAxes: HTMLPreElement;
|
||||
controllerDebugButtons: HTMLPreElement;
|
||||
controllerDebugButtonIndices: HTMLPreElement;
|
||||
|
||||
sessionHelpModal: HTMLDivElement;
|
||||
sessionHelpClose: HTMLButtonElement;
|
||||
sessionHelpShortcut: HTMLDivElement;
|
||||
@@ -78,6 +96,7 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||
@@ -132,6 +151,23 @@ export function resolveRendererDom(): RendererDom {
|
||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
||||
|
||||
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
||||
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
||||
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'),
|
||||
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
||||
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'),
|
||||
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
|
||||
|
||||
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
|
||||
controllerDebugClose: getRequiredElement<HTMLButtonElement>('controllerDebugClose'),
|
||||
controllerDebugCopy: getRequiredElement<HTMLButtonElement>('controllerDebugCopy'),
|
||||
controllerDebugToast: getRequiredElement<HTMLDivElement>('controllerDebugToast'),
|
||||
controllerDebugStatus: getRequiredElement<HTMLDivElement>('controllerDebugStatus'),
|
||||
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
|
||||
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
|
||||
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
|
||||
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||
|
||||
24
src/renderer/yomitan-display-scroll.test.ts
Normal file
24
src/renderer/yomitan-display-scroll.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
// @ts-expect-error Vendor Yomitan modules are JS-only in this repo.
|
||||
import { Display } from '../../vendor/subminer-yomitan/ext/js/display/display.js';
|
||||
|
||||
test('yomitan display scroll bridge uses popup scroll container instead of window scroll', () => {
|
||||
let scrolledTo: { x: number; y: number } | null = null;
|
||||
const result = Display.prototype._onMessageScrollBy.call(
|
||||
{
|
||||
_windowScroll: {
|
||||
x: 24,
|
||||
y: 80,
|
||||
to(x: number, y: number) {
|
||||
scrolledTo = { x, y };
|
||||
},
|
||||
},
|
||||
},
|
||||
{ deltaX: 12, deltaY: -20 },
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(scrolledTo, { x: 36, y: 60 });
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
||||
|
||||
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const;
|
||||
export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'jimaku',
|
||||
'kiku',
|
||||
'controller-select',
|
||||
'controller-debug',
|
||||
] as const;
|
||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
@@ -12,6 +19,7 @@ export const IPC_CHANNELS = {
|
||||
toggleDevTools: 'toggle-dev-tools',
|
||||
toggleOverlay: 'toggle-overlay',
|
||||
saveSubtitlePosition: 'save-subtitle-position',
|
||||
saveControllerPreference: 'save-controller-preference',
|
||||
setMecabEnabled: 'set-mecab-enabled',
|
||||
mpvCommand: 'mpv-command',
|
||||
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
||||
@@ -32,6 +40,7 @@ export const IPC_CHANNELS = {
|
||||
getMecabStatus: 'get-mecab-status',
|
||||
getKeybindings: 'get-keybindings',
|
||||
getConfigShortcuts: 'get-config-shortcuts',
|
||||
getControllerConfig: 'get-controller-config',
|
||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||
focusMainWindow: 'focus-main-window',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ControllerPreferenceUpdate,
|
||||
JimakuDownloadQuery,
|
||||
JimakuFilesQuery,
|
||||
JimakuSearchQuery,
|
||||
@@ -48,6 +49,16 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null {
|
||||
if (!isObject(value)) return null;
|
||||
if (typeof value.preferredGamepadId !== 'string') return null;
|
||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||
return {
|
||||
preferredGamepadId: value.preferredGamepadId,
|
||||
preferredGamepadLabel: value.preferredGamepadLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||
if (!isObject(value)) return null;
|
||||
const { engine, sourceTrackId } = value;
|
||||
|
||||
@@ -65,7 +65,7 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe
|
||||
});
|
||||
});
|
||||
|
||||
test('ensureDefaultConfigBootstrap seeds default config into an existing config directory when missing', () => {
|
||||
test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => {
|
||||
withTempDir((root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -74,13 +74,10 @@ test('ensureDefaultConfigBootstrap seeds default config into an existing config
|
||||
ensureDefaultConfigBootstrap({
|
||||
configDir,
|
||||
configFilePaths: getDefaultConfigFilePaths(configDir),
|
||||
generateTemplate: () => '{\n "logging": {}\n}\n',
|
||||
generateTemplate: () => 'should-not-write',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'),
|
||||
'{\n "logging": {}\n}\n',
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
|
||||
assert.equal(fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'), 'keep\n');
|
||||
});
|
||||
});
|
||||
@@ -94,7 +91,6 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
||||
const state = createDefaultSetupState();
|
||||
state.status = 'completed';
|
||||
state.completionSource = 'user';
|
||||
state.yomitanSetupMode = 'internal';
|
||||
state.lastSeenYomitanDictionaryCount = 2;
|
||||
writeSetupState(statePath, state);
|
||||
|
||||
@@ -102,7 +98,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||
test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
fs.writeFileSync(
|
||||
@@ -119,11 +115,10 @@ test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||
);
|
||||
|
||||
assert.deepEqual(readSetupState(statePath), {
|
||||
version: 3,
|
||||
version: 2,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -136,45 +131,6 @@ test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState migrates completed v2 state to internal yomitan setup mode', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-12T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(readSetupState(statePath), {
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-12T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
|
||||
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
||||
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user