mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
4 Commits
99f4d2baaf
...
v0.6.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
fe2da22d29
|
|||
|
2045ffbff8
|
|||
| 478869ff28 | |||
| 9eed37420e |
83
.github/workflows/release.yml
vendored
83
.github/workflows/release.yml
vendored
@@ -317,7 +317,7 @@ jobs:
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify changelog is ready for tagged release
|
||||
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||
@@ -362,3 +362,84 @@ jobs:
|
||||
for asset in "${artifacts[@]}"; do
|
||||
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||
done
|
||||
|
||||
aur-publish:
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate AUR SSH secret
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure SSH for AUR
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -dm700 ~/.ssh
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Clone AUR repo
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
|
||||
|
||||
- name: Download release assets for AUR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ steps.version.outputs.VERSION }}"
|
||||
install -dm755 .tmp/aur-release-assets
|
||||
gh release download "$version" \
|
||||
--dir .tmp/aur-release-assets \
|
||||
--pattern "SubMiner-${version#v}.AppImage" \
|
||||
--pattern "subminer" \
|
||||
--pattern "subminer-assets.tar.gz"
|
||||
|
||||
- name: Update AUR packaging metadata
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_no_v="${{ steps.version.outputs.VERSION }}"
|
||||
version_no_v="${version_no_v#v}"
|
||||
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
|
||||
bash scripts/update-aur-package.sh \
|
||||
--pkg-dir aur-subminer-bin \
|
||||
--version "${{ steps.version.outputs.VERSION }}" \
|
||||
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
|
||||
--wrapper ".tmp/aur-release-assets/subminer" \
|
||||
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
|
||||
|
||||
- name: Commit and push AUR update
|
||||
working-directory: aur-subminer-bin
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- PKGBUILD .SRCINFO; then
|
||||
echo "AUR packaging already up to date."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
|
||||
git push origin HEAD:master
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,3 +51,4 @@ tests/*
|
||||
.agents/skills/subminer-scrum-master/*
|
||||
!.agents/skills/subminer-scrum-master/SKILL.md
|
||||
favicon.png
|
||||
.claude/*
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.4 (2026-03-15)
|
||||
|
||||
### Internal
|
||||
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
||||
|
||||
## v0.6.3 (2026-03-15)
|
||||
|
||||
### Changed
|
||||
- Overlay: Expanded the `Alt+C` controller modal into an inline config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
|
||||
|
||||
### Internal
|
||||
- Workflow: Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||
- Release: Automate `subminer-bin` AUR package updates from the tagged release workflow.
|
||||
|
||||
## v0.6.2 (2026-03-12)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: TASK-165
|
||||
title: Automate AUR publish on tagged releases
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-14 15:55'
|
||||
updated_date: '2026-03-14 18:40'
|
||||
labels:
|
||||
- release
|
||||
- packaging
|
||||
- linux
|
||||
dependencies:
|
||||
- TASK-161
|
||||
references:
|
||||
- .github/workflows/release.yml
|
||||
- src/release-workflow.test.ts
|
||||
- docs/RELEASING.md
|
||||
- packaging/aur/subminer-bin/PKGBUILD
|
||||
documentation:
|
||||
- docs/plans/2026-03-14-aur-release-sync-design.md
|
||||
- docs/plans/2026-03-14-aur-release-sync.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the tagged release workflow so a successful GitHub release automatically updates the `subminer-bin` AUR package over SSH. Keep the PKGBUILD source-of-truth in this repo so release automation is reviewable and testable instead of depending on an external maintainer checkout.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Repo-tracked AUR packaging source exists for `subminer-bin` and matches the current release artifact layout.
|
||||
- [x] #2 The release workflow clones `ssh://aur@aur.archlinux.org/subminer-bin.git` with a dedicated secret-backed SSH key only after release artifacts are ready.
|
||||
- [x] #3 The workflow updates `pkgver`, regenerates `sha256sums` from the built release artifacts, regenerates `.SRCINFO`, and pushes only when packaging files changed.
|
||||
- [x] #4 Regression coverage fails if the AUR publish job, secret contract, or update steps are removed from the release workflow.
|
||||
- [x] #5 Release docs mention the required `AUR_SSH_PRIVATE_KEY` setup and the new tagged-release side effect.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Record the approved design and implementation plan for direct AUR publishing from the release workflow.
|
||||
2. Add failing release workflow regression tests covering the new AUR publish job, SSH secret, and PKGBUILD/.SRCINFO regeneration steps.
|
||||
3. Reintroduce repo-tracked `packaging/aur/subminer-bin` source files as the maintained AUR template.
|
||||
4. Add a small helper script that updates `pkgver`, computes checksums from release artifacts, and regenerates `.SRCINFO` deterministically.
|
||||
5. Extend `.github/workflows/release.yml` with an AUR publish job that clones the AUR repo over SSH, runs the helper, commits only when needed, and pushes to `aur`.
|
||||
6. Update release docs for the new secret/setup requirements and tagged-release behavior.
|
||||
7. Run targeted workflow tests plus the SubMiner verification lane needed for workflow/docs changes, then update this task with results.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added repo-tracked AUR packaging source under `packaging/aur/subminer-bin/` plus `scripts/update-aur-package.sh` to stamp `pkgver`, compute SHA-256 sums from release assets, and regenerate `.SRCINFO` with `makepkg --printsrcinfo`.
|
||||
|
||||
Extended `.github/workflows/release.yml` with a terminal `aur-publish` job that runs after `release`, validates `AUR_SSH_PRIVATE_KEY`, installs `makepkg`, configures SSH/known_hosts, clones `ssh://aur@aur.archlinux.org/subminer-bin.git`, downloads the just-published `SubMiner-<version>.AppImage`, `subminer`, and `subminer-assets.tar.gz` assets, updates packaging metadata, and pushes only when `PKGBUILD` or `.SRCINFO` changed.
|
||||
|
||||
Updated `src/release-workflow.test.ts` with regression assertions for the AUR publish contract and updated `docs/RELEASING.md` with the new secret/setup requirement.
|
||||
|
||||
Verification run:
|
||||
- `bun test src/release-workflow.test.ts src/ci-workflow.test.ts`
|
||||
- `bash -n scripts/update-aur-package.sh && bash -n packaging/aur/subminer-bin/PKGBUILD`
|
||||
- `cd packaging/aur/subminer-bin && makepkg --printsrcinfo > .SRCINFO`
|
||||
- updater smoke via temp package dir with fake assets and `v9.9.9`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `git submodule update --init --recursive` (required because the worktree lacked release submodules)
|
||||
- `bun run build`
|
||||
- `bun run test:smoke:dist`
|
||||
|
||||
Docs update required: yes, completed in `docs/RELEASING.md`.
|
||||
Changelog fragment required: no; internal release automation only.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Tagged releases now attempt a direct AUR sync for `subminer-bin` using a dedicated SSH private key stored in `AUR_SSH_PRIVATE_KEY`. The release workflow clones the AUR repo after GitHub Release publication, rewrites `PKGBUILD` and `.SRCINFO` from the published release assets, and skips empty pushes. Repo-owned packaging source and workflow regression coverage were added so the automation remains reviewable and testable.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
id: TASK-165
|
||||
title: Make controller configuration easier with inline remapping modal
|
||||
status: To Do
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-03-13 00:10'
|
||||
updated_date: '2026-03-13 00:10'
|
||||
labels:
|
||||
- enhancement
|
||||
- renderer
|
||||
- overlay
|
||||
- input
|
||||
- config
|
||||
dependencies:
|
||||
- TASK-159
|
||||
references:
|
||||
- src/renderer/modals/controller-select.ts
|
||||
- src/renderer/modals/controller-debug.ts
|
||||
- src/renderer/handlers/gamepad-controller.ts
|
||||
- src/renderer/index.html
|
||||
- src/renderer/style.css
|
||||
- src/renderer/utils/dom.ts
|
||||
- src/preload.ts
|
||||
- src/core/services/ipc.ts
|
||||
- src/main.ts
|
||||
- src/types.ts
|
||||
- src/config/definitions/defaults-core.ts
|
||||
- src/config/definitions/options-core.ts
|
||||
- config.example.jsonc
|
||||
- docs/plans/2026-03-13-overlay-controller-config-remap-design.md
|
||||
- docs/plans/2026-03-13-overlay-controller-config-remap.md
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the current controller-selection-only modal with a denser controller configuration surface that keeps device selection and adds inline controller remapping. The new flow should feel like emulator configuration: pick an overlay action, arm capture, then press the matching controller button, trigger, d-pad direction, or stick direction to bind it. Keep the current overlay-local renderer architecture, preserve controller gating to keyboard-only mode, and retain the separate raw debug modal for troubleshooting.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 `Alt+C` opens a controller modal that includes both preferred-controller selection and controller-config editing in one surface.
|
||||
- [ ] #2 Controller device selection uses a compact dropdown or equivalent compact picker instead of the current full-height device list.
|
||||
- [ ] #3 Each remappable controller action shows its current binding and supports learn/capture, clear, and reset-to-default flows.
|
||||
- [ ] #4 Learn mode captures the next fresh controller input edge or stick/d-pad direction, not a held/stale input.
|
||||
- [ ] #5 Captured bindings can represent non-standard controllers without depending only on the browser's standard semantic button names.
|
||||
- [ ] #6 Updated bindings persist through the existing config pipeline and take effect in the renderer without restart unless a field explicitly requires reopen/reload.
|
||||
- [ ] #7 Existing controller behavior remains gated to keyboard-only mode except for the controller action that toggles keyboard-only mode itself.
|
||||
- [ ] #8 Renderer/config/IPC regression tests cover the new modal layout, capture flow, persistence, and runtime mapping behavior.
|
||||
- [ ] #9 Docs/config example explain the new controller-config flow and when to use the debug modal.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add the design doc and implementation plan for inline controller remapping, tied to a new backlog task instead of reopening the already-completed base controller-support task.
|
||||
2. Expand controller config types/defaults/template output so action bindings can store captured input descriptors, not only semantic button-name enums.
|
||||
3. Extend preload/main/IPC write paths from preferred-controller-only saves to full controller-config patching needed by the modal.
|
||||
4. Redesign the controller modal UI into a compact device picker plus action-binding editor with learn, clear, and reset affordances.
|
||||
5. Add renderer capture state and a learn-mode runtime that waits for neutral-to-active transitions before saving a binding.
|
||||
6. Update the gamepad runtime to resolve the new stored descriptors into actions while preserving current gating and repeat/deadzone behavior.
|
||||
7. Keep the raw debug modal as a separate advanced surface; optionally expose copyable input-descriptor text for troubleshooting.
|
||||
8. Add focused regression tests first, then run the maintained gate needed for docs/config/renderer/main changes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Planning only in this pass.
|
||||
|
||||
Current-state findings:
|
||||
|
||||
- `src/renderer/modals/controller-select.ts` only persists `preferredGamepadId` / `preferredGamepadLabel`.
|
||||
- `src/preload.ts`, `src/core/services/ipc.ts`, and `src/main.ts` only expose a narrow save path for preferred controller, not general controller config writes.
|
||||
- `src/renderer/handlers/gamepad-controller.ts` currently resolves actions from semantic button bindings plus a few axis slots; this is fine for defaults but too narrow for emulator-style learn mode on non-standard controllers.
|
||||
- `src/renderer/modals/controller-debug.ts` already provides the raw input surface needed for troubleshooting and for validating capture behavior.
|
||||
|
||||
Recommended direction:
|
||||
|
||||
- keep `Alt+C` as the single controller-config entrypoint
|
||||
- keep `Alt+Shift+C` as raw debug
|
||||
- introduce stored input descriptors for discrete bindings so learn mode can capture buttons, triggers, d-pad directions, and stick directions directly
|
||||
- defer per-controller profiles; keep one global binding set plus preferred-controller selection for this pass
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Planned follow-up work to make controller configuration materially easier than the current “pick preferred device” modal. The proposed change keeps existing controller runtime/debug foundations, but upgrades the selection modal into a compact controller-config surface with inline learn-mode remapping and persistent binding storage.
|
||||
|
||||
Main architectural change in scope: move from semantic-button-only binding storage toward captured input descriptors so the UI can reliably learn from buttons, triggers, d-pad directions, and stick directions on non-standard controllers.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: workflow
|
||||
|
||||
- Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: yomitan
|
||||
|
||||
- Added external-profile mode support that keeps Yomitan dictionaries shared while hardening read-only runtime behavior and first-run setup handling.
|
||||
@@ -53,13 +53,13 @@
|
||||
// ==========================================
|
||||
// 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.
|
||||
// Use Alt+C to pick a preferred controller and remap actions inline with learn mode.
|
||||
// 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.
|
||||
"preferredGamepadId": "", // Preferred controller id saved from the controller config 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.
|
||||
@@ -81,22 +81,64 @@
|
||||
"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.
|
||||
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||
"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.
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"closeLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"toggleKeyboardOnlyMode": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"mineCard": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"quitMpv": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousAudio": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"nextAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"playCurrentAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"leftStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.4 (2026-03-15)
|
||||
- Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
||||
|
||||
## v0.6.3 (2026-03-15)
|
||||
- Expanded `Alt+C` into an inline controller config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
|
||||
- Automated `subminer-bin` AUR package updates from the tagged release workflow.
|
||||
|
||||
## 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.
|
||||
@@ -7,11 +14,11 @@
|
||||
- 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)
|
||||
## v0.6.1 (2026-03-12)
|
||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
||||
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
||||
- Added smooth, slower popup scrolling for controller navigation.
|
||||
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||
- Expanded `Alt+C` into a controller config/remap modal with preferred-controller saving, inline learn mode, and kept `Alt+Shift+C` for raw input debugging.
|
||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
|
||||
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.
|
||||
|
||||
@@ -514,8 +514,10 @@ 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+C` opens the controller config modal, where you can save the selected controller and remap actions inline.
|
||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||
- 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.
|
||||
|
||||
@@ -547,19 +549,19 @@ Important behavior:
|
||||
"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"
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||
"closeLookup": { "kind": "button", "buttonIndex": 1 },
|
||||
"toggleKeyboardOnlyMode": { "kind": "button", "buttonIndex": 3 },
|
||||
"mineCard": { "kind": "button", "buttonIndex": 2 },
|
||||
"quitMpv": { "kind": "button", "buttonIndex": 6 },
|
||||
"previousAudio": { "kind": "none" },
|
||||
"nextAudio": { "kind": "button", "buttonIndex": 5 },
|
||||
"playCurrentAudio": { "kind": "button", "buttonIndex": 4 },
|
||||
"toggleMpvPause": { "kind": "button", "buttonIndex": 9 },
|
||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
|
||||
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
|
||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,10 +583,28 @@ Default logical mapping:
|
||||
- `L3`: toggle mpv pause
|
||||
- `L2` / `R2`: unbound by default
|
||||
|
||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
|
||||
|
||||
If you bind a discrete action to an axis manually, include `direction`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"controller": {
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||
|
||||
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.
|
||||
|
||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const rootChangelogContents = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
|
||||
const readmeContents = readFileSync(new URL('./README.md', import.meta.url), 'utf8');
|
||||
const usageContents = readFileSync(new URL('./usage.md', import.meta.url), 'utf8');
|
||||
const installationContents = readFileSync(new URL('./installation.md', import.meta.url), 'utf8');
|
||||
@@ -10,6 +11,12 @@ const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url
|
||||
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8');
|
||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||
|
||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
||||
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
||||
.map(([heading]) => heading)
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
test('docs reflect current launcher and release surfaces', () => {
|
||||
expect(usageContents).not.toContain('--mode preprocess');
|
||||
expect(usageContents).not.toContain('"automatic" (default)');
|
||||
@@ -37,3 +44,7 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
|
||||
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)');
|
||||
});
|
||||
|
||||
test('docs changelog keeps the newest release headings aligned with the root changelog', () => {
|
||||
expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3));
|
||||
});
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
// ==========================================
|
||||
// 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.
|
||||
// Use Alt+C to pick a preferred controller and remap actions inline with learn mode.
|
||||
// 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.
|
||||
"preferredGamepadId": "", // Preferred controller id saved from the controller config 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.
|
||||
@@ -81,22 +81,64 @@
|
||||
"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.
|
||||
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||
"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.
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"closeLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"toggleKeyboardOnlyMode": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"mineCard": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"quitMpv": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousAudio": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"nextAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"playCurrentAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"leftStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -75,8 +75,8 @@ These overlay-local shortcuts are fixed and open controller utilities for the Ch
|
||||
|
||||
| Shortcut | Action | Configurable |
|
||||
| ------------- | ------------------------------ | ------------ |
|
||||
| `Alt+C` | Open controller selection modal | Fixed |
|
||||
| `Alt+Shift+C` | Open controller debug modal | Fixed |
|
||||
| `Alt+C` | Open controller config + remap 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.
|
||||
|
||||
|
||||
@@ -254,10 +254,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
||||
|
||||
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.
|
||||
3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline.
|
||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||
6. 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.
|
||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads.
|
||||
|
||||
### Default Button Mapping
|
||||
|
||||
@@ -278,10 +280,11 @@ By default SubMiner uses the first connected controller. Press `Alt+C` in the ov
|
||||
| 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 |
|
||||
| Left stick vertical | Scroll Yomitan popup |
|
||||
| Right stick vertical | Jump through Yomitan popup |
|
||||
| D-pad | Fallback for stick navigation when configured |
|
||||
|
||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
||||
|
||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||
|
||||
|
||||
@@ -20,3 +20,5 @@ Notes:
|
||||
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# SubMiner Change Verification Skill Design
|
||||
|
||||
**Date:** 2026-03-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create a SubMiner-specific skill that agents can use to verify code changes with automated checks. The skill must support both targeted regression testing during debugging and pre-handoff verification before final response.
|
||||
|
||||
## Skill Contract
|
||||
|
||||
- **Name:** `subminer-change-verification`
|
||||
- **Trigger:** Use when working in the SubMiner repo and you need to verify code changes actually work, especially for launcher, mpv, plugin, overlay, runtime, Electron, or env-sensitive behavior.
|
||||
- **Default posture:** cheap-first; prefer repo-native tests and narrow lanes before broader or GUI-dependent verification.
|
||||
- **Outputs:**
|
||||
- verification summary
|
||||
- exact commands run
|
||||
- artifact paths for logs, captured summaries, and preserved temp state on failures
|
||||
- skipped lanes and blockers
|
||||
- **Non-goals:**
|
||||
- replacing the repo's native tests
|
||||
- launching real GUI apps for every change
|
||||
- default visual regression or pixel-diff workflows
|
||||
|
||||
## Lane Selection
|
||||
|
||||
The skill chooses lanes from the diff or explicit file list.
|
||||
|
||||
- **`docs`**
|
||||
- For `docs-site/`, `docs/`, and similar documentation-only changes.
|
||||
- Prefer `bun run docs:test` and `bun run docs:build`.
|
||||
- **`config`**
|
||||
- For `src/config/`, config example generation/verification paths, and config-template-sensitive changes.
|
||||
- Prefer `bun run test:config`.
|
||||
- **`core`**
|
||||
- For general source-level changes where type safety and the fast maintained lane are the best cheap signal.
|
||||
- Prefer `bun run typecheck` and `bun run test:fast`.
|
||||
- **`launcher-plugin`**
|
||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||
- Prefer `bun run test:launcher:smoke:src` and `bun run test:plugin:src`.
|
||||
- **`runtime-compat`**
|
||||
- For runtime/composition/bundled behavior where dist-sensitive validation matters.
|
||||
- Prefer `bun run build`, `bun run test:runtime:compat`, and `bun run test:smoke:dist`.
|
||||
- **`real-gui`**
|
||||
- Reserved for cases where actual Electron/mpv/window behavior must be validated.
|
||||
- Not part of the default lane set; the classifier marks these changes as candidates so the agent can escalate deliberately.
|
||||
|
||||
## Escalation Rules
|
||||
|
||||
1. Start with the narrowest lane that credibly exercises the changed behavior.
|
||||
2. If a narrow lane fails in a way that suggests broader fallout, expand once.
|
||||
3. If a change touches launcher/mpv/plugin/runtime/overlay/window tracking paths, include the relevant specialized lanes before falling back to broad suites.
|
||||
4. Treat real GUI/mpv verification as opt-in escalation:
|
||||
- use only when cheaper evidence is insufficient
|
||||
- allow for platform/display/permission blockers
|
||||
- report skipped/blocker states explicitly
|
||||
|
||||
## Helper Script Design
|
||||
|
||||
The skill uses two small shell helpers:
|
||||
|
||||
- **`scripts/classify_subminer_diff.sh`**
|
||||
- Accepts explicit paths or discovers local changes from git.
|
||||
- Emits lane suggestions and flags in a simple line-oriented format.
|
||||
- Marks real GUI-sensitive paths as `flag:real-gui-candidate` instead of forcing GUI execution.
|
||||
- **`scripts/verify_subminer_change.sh`**
|
||||
- Creates an artifact directory under `.tmp/skill-verification/<timestamp>/`.
|
||||
- Selects lanes from the classifier unless lanes are supplied explicitly.
|
||||
- Runs repo-native commands in a stable order and captures stdout/stderr per step.
|
||||
- Writes a compact `summary.json` and a human-readable `summary.txt`.
|
||||
- Skips real GUI verification unless explicitly enabled.
|
||||
|
||||
## Artifact Contract
|
||||
|
||||
Each invocation should create:
|
||||
|
||||
- `summary.json`
|
||||
- `summary.txt`
|
||||
- `classification.txt`
|
||||
- `env.txt`
|
||||
- `lanes.txt`
|
||||
- `steps.tsv`
|
||||
- `steps/<step>.stdout.log`
|
||||
- `steps/<step>.stderr.log`
|
||||
|
||||
Failures should preserve the artifact directory and identify the exact failing command and log paths.
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
1. Inspect changed files or requested area.
|
||||
2. Classify the change into verification lanes.
|
||||
3. Run the cheapest sufficient lane set.
|
||||
4. Escalate only if evidence is insufficient.
|
||||
5. Escalate to real GUI/mpv only for actual Electron/mpv/window behavior claims.
|
||||
6. Return a short report with:
|
||||
- pass/fail/skipped per lane
|
||||
- exact commands run
|
||||
- artifact paths
|
||||
- blockers/gaps
|
||||
|
||||
## Initial Implementation Scope
|
||||
|
||||
- Ship the skill entrypoint plus the classifier/verifier helpers.
|
||||
- Make real GUI verification an explicit future hook rather than a default workflow.
|
||||
- Verify the new skill locally with representative classifier output and artifact generation.
|
||||
@@ -1,111 +0,0 @@
|
||||
# SubMiner Scrum Master Skill Design
|
||||
|
||||
**Date:** 2026-03-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create a repo-local skill that can take incoming requests, bugs, or issues in the SubMiner repo, decide whether backlog tracking is warranted, create or update backlog work when appropriate, plan the implementation, dispatch one or more subagents, and ensure verification happens before handoff.
|
||||
|
||||
## Skill Contract
|
||||
|
||||
- **Name:** `subminer-scrum-master`
|
||||
- **Location:** `.agents/skills/subminer-scrum-master/`
|
||||
- **Use when:** the user gives a feature request, bug report, issue, refactor, or implementation ask in the SubMiner repo and the agent should own intake, planning, backlog hygiene, dispatch, and completion flow.
|
||||
- **Responsibilities:**
|
||||
- assess whether backlog tracking is warranted
|
||||
- if needed, search/update/create proper backlog structure
|
||||
- write a plan before dispatching coding work
|
||||
- choose sequential vs parallel execution
|
||||
- assign explicit ownership to workers
|
||||
- require verification before final handoff
|
||||
- **Limits:**
|
||||
- not the default code implementer unless delegation would be wasteful
|
||||
- no overlapping parallel write scopes
|
||||
- no skipping planning before dispatch
|
||||
- no skipping verification
|
||||
- must pause for ambiguous, risky, or external side-effect work
|
||||
|
||||
## Backlog Decision Rules
|
||||
|
||||
Backlog use is conditional, not mandatory.
|
||||
|
||||
- **Skip backlog when:**
|
||||
- question only
|
||||
- obvious mechanical edit
|
||||
- tiny isolated change with no real planning
|
||||
- **Use backlog when:**
|
||||
- implementation requires planning
|
||||
- scope/approach needs decisions
|
||||
- multiple phases or subsystems
|
||||
- likely subagent dispatch
|
||||
- work should remain traceable
|
||||
|
||||
When backlog is used:
|
||||
- search first
|
||||
- update existing matching work when appropriate
|
||||
- otherwise create standalone task or parent task
|
||||
- use parent + subtasks for multi-part work
|
||||
- record the implementation plan before coding starts
|
||||
|
||||
## Orchestration Policy
|
||||
|
||||
The skill orchestrates; workers implement.
|
||||
|
||||
- **No dispatch** for trivial/mechanical work
|
||||
- **Single worker** for focused single-scope work
|
||||
- **Parallel workers** only for clearly disjoint scopes
|
||||
- **Sequential flow** for shared files, runtime coupling, or unclear boundaries
|
||||
|
||||
Every worker should receive:
|
||||
- owned files/modules
|
||||
- explicit reminder not to revert unrelated edits
|
||||
- requirement to report changed files, tests run, and blockers
|
||||
|
||||
## Verification Policy
|
||||
|
||||
Every nontrivial code task gets verification.
|
||||
|
||||
- prefer `subminer-change-verification`
|
||||
- use cheap-first lanes
|
||||
- escalate only when needed
|
||||
- accept worker-run verification only if it is clearly relevant and sufficient
|
||||
- run a consolidating final verification pass when the scrum master needs stronger evidence
|
||||
|
||||
## Representative Flows
|
||||
|
||||
### Trivial fix, no backlog
|
||||
|
||||
1. assess request as mechanical or narrowly reversible
|
||||
2. skip backlog
|
||||
3. keep a short internal plan
|
||||
4. implement directly or use one worker if helpful
|
||||
5. run targeted verification
|
||||
6. report concise summary
|
||||
|
||||
### Single-task implementation
|
||||
|
||||
1. search backlog
|
||||
2. create/update one task
|
||||
3. record plan
|
||||
4. dispatch one worker
|
||||
5. integrate result
|
||||
6. run verification
|
||||
7. update task and report outcome
|
||||
|
||||
### Multi-part feature
|
||||
|
||||
1. search backlog
|
||||
2. create/update parent task
|
||||
3. create subtasks for distinct deliverables/phases
|
||||
4. record sequencing in the plan
|
||||
5. dispatch workers only where write scopes do not overlap
|
||||
6. integrate
|
||||
7. run consolidated verification
|
||||
8. update task state and report outcome
|
||||
|
||||
## V1 Scope
|
||||
|
||||
- instruction-heavy `SKILL.md`
|
||||
- no helper scripts unless orchestration becomes too repetitive
|
||||
- strong coordination with existing Backlog workflow and `subminer-change-verification`
|
||||
@@ -1,110 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
# 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`.
|
||||
@@ -17,7 +17,9 @@ 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 {
|
||||
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>)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.4",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -54,7 +54,7 @@
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
36
packaging/aur/subminer-bin/.SRCINFO
Normal file
36
packaging/aur/subminer-bin/.SRCINFO
Normal file
@@ -0,0 +1,36 @@
|
||||
pkgbase = subminer-bin
|
||||
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
|
||||
pkgver = 0.6.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/ksyasuda/SubMiner
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
depends = bun
|
||||
depends = fuse2
|
||||
depends = glibc
|
||||
depends = mpv
|
||||
depends = zlib-ng-compat
|
||||
optdepends = ffmpeg: media extraction and screenshot generation
|
||||
optdepends = ffmpegthumbnailer: faster thumbnail previews in the launcher
|
||||
optdepends = fzf: terminal media picker in the subminer wrapper
|
||||
optdepends = rofi: GUI media picker in the subminer wrapper
|
||||
optdepends = chafa: image previews in the fzf picker
|
||||
optdepends = yt-dlp: YouTube playback and subtitle extraction
|
||||
optdepends = mecab: optional Japanese metadata enrichment
|
||||
optdepends = mecab-ipadic: dictionary for MeCab metadata enrichment
|
||||
optdepends = python-guessit: improved AniSkip title and episode inference
|
||||
optdepends = alass-git: preferred subtitle synchronization engine
|
||||
optdepends = python-ffsubsync: fallback subtitle synchronization engine
|
||||
provides = subminer=0.6.2
|
||||
conflicts = subminer
|
||||
noextract = SubMiner-0.6.2.AppImage
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
|
||||
source = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
|
||||
source = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
|
||||
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
|
||||
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
|
||||
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
|
||||
|
||||
pkgname = subminer-bin
|
||||
64
packaging/aur/subminer-bin/PKGBUILD
Normal file
64
packaging/aur/subminer-bin/PKGBUILD
Normal file
@@ -0,0 +1,64 @@
|
||||
# Maintainer: Kyle Yasuda <suda@sudacode.com>
|
||||
|
||||
pkgname=subminer-bin
|
||||
pkgver=0.6.2
|
||||
pkgrel=1
|
||||
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/ksyasuda/SubMiner'
|
||||
license=('GPL-3.0-or-later')
|
||||
options=('!strip' '!debug')
|
||||
depends=(
|
||||
'bun'
|
||||
'fuse2'
|
||||
'glibc'
|
||||
'mpv'
|
||||
'zlib-ng-compat'
|
||||
)
|
||||
optdepends=(
|
||||
'ffmpeg: media extraction and screenshot generation'
|
||||
'ffmpegthumbnailer: faster thumbnail previews in the launcher'
|
||||
'fzf: terminal media picker in the subminer wrapper'
|
||||
'rofi: GUI media picker in the subminer wrapper'
|
||||
'chafa: image previews in the fzf picker'
|
||||
'yt-dlp: YouTube playback and subtitle extraction'
|
||||
'mecab: optional Japanese metadata enrichment'
|
||||
'mecab-ipadic: dictionary for MeCab metadata enrichment'
|
||||
'python-guessit: improved AniSkip title and episode inference'
|
||||
'alass-git: preferred subtitle synchronization engine'
|
||||
'python-ffsubsync: fallback subtitle synchronization engine'
|
||||
)
|
||||
provides=("subminer=${pkgver}")
|
||||
conflicts=('subminer')
|
||||
source=(
|
||||
"SubMiner-${pkgver}.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/SubMiner-${pkgver}.AppImage"
|
||||
"subminer::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer"
|
||||
"subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
|
||||
)
|
||||
sha256sums=(
|
||||
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
|
||||
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
|
||||
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
|
||||
)
|
||||
noextract=("SubMiner-${pkgver}.AppImage")
|
||||
|
||||
package() {
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
|
||||
install -Dm755 "${srcdir}/SubMiner-${pkgver}.AppImage" \
|
||||
"${pkgdir}/opt/SubMiner/SubMiner.AppImage"
|
||||
install -dm755 "${pkgdir}/opt/SubMiner"
|
||||
ln -s '/opt/SubMiner/SubMiner.AppImage' "${pkgdir}/usr/bin/SubMiner.AppImage"
|
||||
|
||||
install -Dm755 "${srcdir}/subminer" "${pkgdir}/usr/bin/subminer"
|
||||
|
||||
install -Dm644 "${srcdir}/config.example.jsonc" \
|
||||
"${pkgdir}/usr/share/SubMiner/config.example.jsonc"
|
||||
install -Dm644 "${srcdir}/plugin/subminer.conf" \
|
||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
||||
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
||||
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
||||
}
|
||||
15
release/release-notes.md
Normal file
15
release/release-notes.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Highlights
|
||||
### Internal
|
||||
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
214
scripts/update-aur-package.sh
Executable file
214
scripts/update-aur-package.sh
Executable file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/update-aur-package.sh --pkg-dir <dir> --version <version> --appimage <path> --wrapper <path> --assets <path>
|
||||
EOF
|
||||
}
|
||||
|
||||
pkg_dir=
|
||||
version=
|
||||
appimage=
|
||||
wrapper=
|
||||
assets=
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pkg-dir)
|
||||
pkg_dir="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
version="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--appimage)
|
||||
appimage="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--wrapper)
|
||||
wrapper="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--assets)
|
||||
assets="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$assets" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${version#v}"
|
||||
pkgbuild="${pkg_dir}/PKGBUILD"
|
||||
srcinfo="${pkg_dir}/.SRCINFO"
|
||||
|
||||
if [[ ! -f "$pkgbuild" ]]; then
|
||||
echo "Missing PKGBUILD at $pkgbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for artifact in "$appimage" "$wrapper" "$assets"; do
|
||||
if [[ ! -f "$artifact" ]]; then
|
||||
echo "Missing artifact: $artifact" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
awk \
|
||||
-v version="$version" \
|
||||
-v sum_appimage="${sha256sums[0]}" \
|
||||
-v sum_wrapper="${sha256sums[1]}" \
|
||||
-v sum_assets="${sha256sums[2]}" \
|
||||
'
|
||||
BEGIN {
|
||||
in_sha_block = 0
|
||||
found_pkgver = 0
|
||||
found_sha_block = 0
|
||||
}
|
||||
/^pkgver=/ {
|
||||
print "pkgver=" version
|
||||
found_pkgver = 1
|
||||
next
|
||||
}
|
||||
/^sha256sums=\(/ {
|
||||
print "sha256sums=("
|
||||
print "\047" sum_appimage "\047"
|
||||
print "\047" sum_wrapper "\047"
|
||||
print "\047" sum_assets "\047"
|
||||
in_sha_block = 1
|
||||
next
|
||||
}
|
||||
in_sha_block {
|
||||
if ($0 ~ /^\)/) {
|
||||
print ")"
|
||||
in_sha_block = 0
|
||||
found_sha_block = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{
|
||||
print
|
||||
}
|
||||
END {
|
||||
if (!found_pkgver) {
|
||||
print "Missing pkgver= line in PKGBUILD" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (!found_sha_block) {
|
||||
print "Missing sha256sums block in PKGBUILD" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$pkgbuild" > "$tmpfile"
|
||||
mv "$tmpfile" "$pkgbuild"
|
||||
|
||||
if [[ ! -f "$srcinfo" ]]; then
|
||||
echo "Missing .SRCINFO at $srcinfo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
awk \
|
||||
-v version="$version" \
|
||||
-v sum_appimage="${sha256sums[0]}" \
|
||||
-v sum_wrapper="${sha256sums[1]}" \
|
||||
-v sum_assets="${sha256sums[2]}" \
|
||||
'
|
||||
BEGIN {
|
||||
sha_index = 0
|
||||
found_pkgver = 0
|
||||
found_provides = 0
|
||||
found_noextract = 0
|
||||
found_source_appimage = 0
|
||||
found_source_wrapper = 0
|
||||
found_source_assets = 0
|
||||
}
|
||||
/^\tpkgver = / {
|
||||
print "\tpkgver = " version
|
||||
found_pkgver = 1
|
||||
next
|
||||
}
|
||||
/^\tprovides = subminer=/ {
|
||||
print "\tprovides = subminer=" version
|
||||
found_provides = 1
|
||||
next
|
||||
}
|
||||
/^\tnoextract = SubMiner-.*\.AppImage$/ {
|
||||
print "\tnoextract = SubMiner-" version ".AppImage"
|
||||
found_noextract = 1
|
||||
next
|
||||
}
|
||||
/^\tsource = SubMiner-.*\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/SubMiner-.*\.AppImage$/ {
|
||||
print "\tsource = SubMiner-" version ".AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/SubMiner-" version ".AppImage"
|
||||
found_source_appimage = 1
|
||||
next
|
||||
}
|
||||
/^\tsource = subminer::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer$/ {
|
||||
print "\tsource = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer"
|
||||
found_source_wrapper = 1
|
||||
next
|
||||
}
|
||||
/^\tsource = subminer-assets\.tar\.gz::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer-assets\.tar\.gz$/ {
|
||||
print "\tsource = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer-assets.tar.gz"
|
||||
found_source_assets = 1
|
||||
next
|
||||
}
|
||||
/^\tsha256sums = / {
|
||||
sha_index += 1
|
||||
if (sha_index == 1) {
|
||||
print "\tsha256sums = " sum_appimage
|
||||
next
|
||||
}
|
||||
if (sha_index == 2) {
|
||||
print "\tsha256sums = " sum_wrapper
|
||||
next
|
||||
}
|
||||
if (sha_index == 3) {
|
||||
print "\tsha256sums = " sum_assets
|
||||
next
|
||||
}
|
||||
}
|
||||
{
|
||||
print
|
||||
}
|
||||
END {
|
||||
if (!found_pkgver) {
|
||||
print "Missing pkgver entry in .SRCINFO" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (!found_provides) {
|
||||
print "Missing provides entry in .SRCINFO" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (!found_noextract) {
|
||||
print "Missing noextract entry in .SRCINFO" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (!found_source_appimage || !found_source_wrapper || !found_source_assets) {
|
||||
print "Missing source entry in .SRCINFO" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (sha_index < 3) {
|
||||
print "Missing sha256sums entries in .SRCINFO" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$srcinfo" > "$tmpfile"
|
||||
mv "$tmpfile" "$srcinfo"
|
||||
67
scripts/update-aur-package.test.ts
Normal file
67
scripts/update-aur-package.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function createWorkspace(name: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
}
|
||||
|
||||
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
||||
const workspace = createWorkspace('subminer-aur-package');
|
||||
const pkgDir = path.join(workspace, 'aur-subminer-bin');
|
||||
const appImagePath = path.join(workspace, 'SubMiner-0.6.3.AppImage');
|
||||
const wrapperPath = path.join(workspace, 'subminer');
|
||||
const assetsPath = path.join(workspace, 'subminer-assets.tar.gz');
|
||||
|
||||
fs.mkdirSync(pkgDir, { recursive: true });
|
||||
fs.copyFileSync('packaging/aur/subminer-bin/PKGBUILD', path.join(pkgDir, 'PKGBUILD'));
|
||||
fs.copyFileSync('packaging/aur/subminer-bin/.SRCINFO', path.join(pkgDir, '.SRCINFO'));
|
||||
fs.writeFileSync(appImagePath, 'appimage');
|
||||
fs.writeFileSync(wrapperPath, 'wrapper');
|
||||
fs.writeFileSync(assetsPath, 'assets');
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'scripts/update-aur-package.sh',
|
||||
'--pkg-dir',
|
||||
pkgDir,
|
||||
'--version',
|
||||
'v0.6.3',
|
||||
'--appimage',
|
||||
appImagePath,
|
||||
'--wrapper',
|
||||
wrapperPath,
|
||||
'--assets',
|
||||
assetsPath,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
|
||||
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
|
||||
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
|
||||
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
|
||||
);
|
||||
|
||||
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
||||
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
||||
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
||||
assert.match(
|
||||
srcinfo,
|
||||
/^\tsource = SubMiner-0\.6\.3\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v0\.6\.3\/SubMiner-0\.6\.3\.AppImage$/m,
|
||||
);
|
||||
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[0]}$`, 'm'));
|
||||
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[1]}$`, 'm'));
|
||||
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[2]}$`, 'm'));
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1168,12 +1168,103 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
|
||||
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');
|
||||
assert.deepEqual(config.controller.bindings.toggleLookup, { kind: 'button', buttonIndex: 2 });
|
||||
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
|
||||
assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
|
||||
assert.deepEqual(config.controller.bindings.toggleMpvPause, { kind: 'button', buttonIndex: 9 });
|
||||
assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
|
||||
kind: 'axis',
|
||||
axisIndex: 3,
|
||||
dpadFallback: 'horizontal',
|
||||
});
|
||||
assert.deepEqual(config.controller.bindings.rightStickVertical, {
|
||||
kind: 'axis',
|
||||
axisIndex: 1,
|
||||
dpadFallback: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
test('parses descriptor-based controller bindings', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
|
||||
"closeLookup": { "kind": "axis", "axisIndex": 4, "direction": "negative" },
|
||||
"playCurrentAudio": { "kind": "none" },
|
||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" },
|
||||
"leftStickVertical": { "kind": "axis", "axisIndex": 2, "dpadFallback": "vertical" }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.deepEqual(config.controller.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
assert.deepEqual(config.controller.bindings.closeLookup, {
|
||||
kind: 'axis',
|
||||
axisIndex: 4,
|
||||
direction: 'negative',
|
||||
});
|
||||
assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
|
||||
assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
|
||||
kind: 'axis',
|
||||
axisIndex: 7,
|
||||
dpadFallback: 'none',
|
||||
});
|
||||
assert.deepEqual(config.controller.bindings.leftStickVertical, {
|
||||
kind: 'axis',
|
||||
axisIndex: 2,
|
||||
dpadFallback: 'vertical',
|
||||
});
|
||||
});
|
||||
|
||||
test('controller descriptor config rejects malformed binding objects', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": -1 },
|
||||
"closeLookup": { "kind": "axis", "axisIndex": 1, "direction": "sideways" },
|
||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "diagonal" }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.deepEqual(
|
||||
config.controller.bindings.toggleLookup,
|
||||
DEFAULT_CONFIG.controller.bindings.toggleLookup,
|
||||
);
|
||||
assert.deepEqual(
|
||||
config.controller.bindings.closeLookup,
|
||||
DEFAULT_CONFIG.controller.bindings.closeLookup,
|
||||
);
|
||||
assert.deepEqual(
|
||||
config.controller.bindings.leftStickHorizontal,
|
||||
DEFAULT_CONFIG.controller.bindings.leftStickHorizontal,
|
||||
);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
|
||||
@@ -1195,14 +1286,32 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze
|
||||
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.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);
|
||||
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', () => {
|
||||
@@ -1224,12 +1333,18 @@ test('controller button index config rejects fractional values', () => {
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
|
||||
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.select'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
||||
true,
|
||||
@@ -1801,6 +1916,24 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"preferredGamepadId": "",? \/\/ Preferred controller id saved from the controller config modal\./,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"toggleLookup": \{\s*"kind": "button"[\s\S]*\},? \/\/ Controller binding descriptor for toggling lookup\. Use Alt\+C learn mode or set a raw button\/axis descriptor manually\./,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"kind": "button",? \/\/ Discrete binding input source kind\. When kind is "axis", set both axisIndex and direction\. Values: none \| button \| axis/,
|
||||
);
|
||||
assert.match(output, /"toggleLookup": \{\s*"kind": "button"/);
|
||||
assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/);
|
||||
assert.match(
|
||||
output,
|
||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -58,19 +58,19 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
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',
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -4,20 +4,76 @@ import { ConfigOptionRegistryEntry } from './shared';
|
||||
export function buildCoreConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
const controllerButtonEnumValues = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
];
|
||||
const discreteBindings = [
|
||||
{
|
||||
id: 'toggleLookup',
|
||||
defaultValue: defaultConfig.controller.bindings.toggleLookup,
|
||||
description: 'Controller binding descriptor for toggling lookup.',
|
||||
},
|
||||
{
|
||||
id: 'closeLookup',
|
||||
defaultValue: defaultConfig.controller.bindings.closeLookup,
|
||||
description: 'Controller binding descriptor for closing lookup.',
|
||||
},
|
||||
{
|
||||
id: 'toggleKeyboardOnlyMode',
|
||||
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
|
||||
description: 'Controller binding descriptor for toggling keyboard-only mode.',
|
||||
},
|
||||
{
|
||||
id: 'mineCard',
|
||||
defaultValue: defaultConfig.controller.bindings.mineCard,
|
||||
description: 'Controller binding descriptor for mining the active card.',
|
||||
},
|
||||
{
|
||||
id: 'quitMpv',
|
||||
defaultValue: defaultConfig.controller.bindings.quitMpv,
|
||||
description: 'Controller binding descriptor for quitting mpv.',
|
||||
},
|
||||
{
|
||||
id: 'previousAudio',
|
||||
defaultValue: defaultConfig.controller.bindings.previousAudio,
|
||||
description: 'Controller binding descriptor for previous Yomitan audio.',
|
||||
},
|
||||
{
|
||||
id: 'nextAudio',
|
||||
defaultValue: defaultConfig.controller.bindings.nextAudio,
|
||||
description: 'Controller binding descriptor for next Yomitan audio.',
|
||||
},
|
||||
{
|
||||
id: 'playCurrentAudio',
|
||||
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
|
||||
description: 'Controller binding descriptor for playing the current Yomitan audio.',
|
||||
},
|
||||
{
|
||||
id: 'toggleMpvPause',
|
||||
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
||||
description: 'Controller binding descriptor for toggling mpv play/pause.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const axisBindings = [
|
||||
{
|
||||
id: 'leftStickHorizontal',
|
||||
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
|
||||
description: 'Axis binding descriptor used for left/right token selection.',
|
||||
},
|
||||
{
|
||||
id: 'leftStickVertical',
|
||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
||||
description: 'Axis binding descriptor used for primary popup scrolling.',
|
||||
},
|
||||
{
|
||||
id: 'rightStickHorizontal',
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
||||
description: 'Axis binding descriptor reserved for alternate right-stick mappings.',
|
||||
},
|
||||
{
|
||||
id: 'rightStickVertical',
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
||||
description: 'Axis binding descriptor used for popup page jumps.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -37,7 +93,7 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: 'controller.preferredGamepadId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.controller.preferredGamepadId,
|
||||
description: 'Preferred controller id saved from the controller selection modal.',
|
||||
description: 'Preferred controller id saved from the controller config modal.',
|
||||
},
|
||||
{
|
||||
path: 'controller.preferredGamepadLabel',
|
||||
@@ -74,13 +130,15 @@ export function buildCoreConfigOptionRegistry(
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'digital', 'analog'],
|
||||
defaultValue: defaultConfig.controller.triggerInputMode,
|
||||
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
||||
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.',
|
||||
description:
|
||||
'Minimum analog trigger value required when trigger input uses auto or analog mode.',
|
||||
},
|
||||
{
|
||||
path: 'controller.repeatDelayMs',
|
||||
@@ -94,6 +152,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.controller.repeatIntervalMs,
|
||||
description: 'Repeat interval for held controller actions.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.controller.buttonIndices,
|
||||
description:
|
||||
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.select',
|
||||
kind: 'number',
|
||||
@@ -161,96 +226,79 @@ export function buildCoreConfigOptionRegistry(
|
||||
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: 'controller.bindings',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.controller.bindings,
|
||||
description:
|
||||
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
||||
},
|
||||
...discreteBindings.flatMap((binding) => [
|
||||
{
|
||||
path: `controller.bindings.${binding.id}`,
|
||||
kind: 'object' as const,
|
||||
defaultValue: binding.defaultValue,
|
||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.kind`,
|
||||
kind: 'enum' as const,
|
||||
enumValues: ['none', 'button', 'axis'],
|
||||
defaultValue: binding.defaultValue.kind,
|
||||
description:
|
||||
'Discrete binding input source kind. When kind is "axis", set both axisIndex and direction.',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.buttonIndex`,
|
||||
kind: 'number' as const,
|
||||
defaultValue:
|
||||
binding.defaultValue.kind === 'button' ? binding.defaultValue.buttonIndex : undefined,
|
||||
description: 'Raw button index captured for this discrete controller action.',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.axisIndex`,
|
||||
kind: 'number' as const,
|
||||
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
|
||||
description: 'Raw axis index captured for this discrete controller action.',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.direction`,
|
||||
kind: 'enum' as const,
|
||||
enumValues: ['negative', 'positive'],
|
||||
defaultValue:
|
||||
binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined,
|
||||
description:
|
||||
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
||||
},
|
||||
]),
|
||||
...axisBindings.flatMap((binding) => [
|
||||
{
|
||||
path: `controller.bindings.${binding.id}`,
|
||||
kind: 'object' as const,
|
||||
defaultValue: binding.defaultValue,
|
||||
description: `${binding.description} Use Alt+C learn mode or set a raw axis descriptor manually.`,
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.kind`,
|
||||
kind: 'enum' as const,
|
||||
enumValues: ['none', 'axis'],
|
||||
defaultValue: binding.defaultValue.kind,
|
||||
description: 'Analog binding input source kind.',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.axisIndex`,
|
||||
kind: 'number' as const,
|
||||
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
|
||||
description: 'Raw axis index captured for this analog controller action.',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.dpadFallback`,
|
||||
kind: 'enum' as const,
|
||||
enumValues: ['none', 'horizontal', 'vertical'],
|
||||
defaultValue:
|
||||
binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined,
|
||||
description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.',
|
||||
},
|
||||
]),
|
||||
{
|
||||
path: 'texthooker.launchAtStartup',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -38,7 +38,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
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.',
|
||||
'Use Alt+C to pick a preferred controller and remap actions inline with learn mode.',
|
||||
'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.',
|
||||
],
|
||||
|
||||
@@ -1,23 +1,141 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisBindingConfig,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
ControllerDiscreteBindingConfig,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
import { ResolveContext } from './context';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
const CONTROLLER_BUTTON_BINDINGS = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||
|
||||
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
Exclude<ControllerButtonBinding, 'none'>,
|
||||
keyof Required<ControllerButtonIndicesConfig>
|
||||
> = {
|
||||
select: 'select',
|
||||
buttonSouth: 'buttonSouth',
|
||||
buttonEast: 'buttonEast',
|
||||
buttonNorth: 'buttonNorth',
|
||||
buttonWest: 'buttonWest',
|
||||
leftShoulder: 'leftShoulder',
|
||||
rightShoulder: 'rightShoulder',
|
||||
leftStickPress: 'leftStickPress',
|
||||
rightStickPress: 'rightStickPress',
|
||||
leftTrigger: 'leftTrigger',
|
||||
rightTrigger: 'rightTrigger',
|
||||
};
|
||||
|
||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||
leftStickHorizontal: 'horizontal',
|
||||
leftStickVertical: 'vertical',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||
}
|
||||
|
||||
function resolveLegacyDiscreteBinding(
|
||||
value: ControllerButtonBinding,
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
||||
): ResolvedControllerDiscreteBinding {
|
||||
if (value === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return {
|
||||
kind: 'button',
|
||||
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLegacyAxisBinding(
|
||||
value: ControllerAxisBinding,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
): ResolvedControllerAxisBinding {
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
||||
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' && Number.isInteger(value.buttonIndex) && value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBindingObject(
|
||||
value: unknown,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
): ResolvedControllerAxisBinding | null {
|
||||
if (isObject(value) && value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
||||
if (typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: value.axisIndex,
|
||||
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -178,7 +296,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
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.');
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +311,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
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.');
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected number between 0 and 1.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,19 +364,27 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
] as const;
|
||||
|
||||
for (const key of buttonBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
const bindingValue = src.controller.bindings[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
|
||||
legacyValue as ControllerButtonBinding,
|
||||
resolved.controller.buttonIndices,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||
if (parsedObject) {
|
||||
resolved.controller.bindings[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
bindingValue,
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -261,19 +397,31 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
] as const;
|
||||
|
||||
for (const key of axisBindingKeys) {
|
||||
const value = asString(src.controller.bindings[key]);
|
||||
const bindingValue = src.controller.bindings[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
value !== undefined &&
|
||||
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
|
||||
) {
|
||||
resolved.controller.bindings[key] =
|
||||
value as (typeof resolved.controller.bindings)[typeof key];
|
||||
} else if (src.controller.bindings[key] !== undefined) {
|
||||
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
|
||||
legacyValue as ControllerAxisBinding,
|
||||
key,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (legacyValue === 'none') {
|
||||
resolved.controller.bindings[key] = { kind: 'none' };
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
||||
if (parsedObject) {
|
||||
resolved.controller.bindings[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
src.controller.bindings[key],
|
||||
bindingValue,
|
||||
resolved.controller.bindings[key],
|
||||
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
|
||||
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,50 @@ function createFakeIpcRegistrar(): {
|
||||
};
|
||||
}
|
||||
|
||||
function createControllerConfigFixture() {
|
||||
return {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 960,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto' as const,
|
||||
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: { kind: 'button' as const, buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button' as const, buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button' as const, buttonIndex: 3 },
|
||||
mineCard: { kind: 'button' as const, buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button' as const, buttonIndex: 6 },
|
||||
previousAudio: { kind: 'button' as const, buttonIndex: 4 },
|
||||
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
|
||||
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
|
||||
leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const },
|
||||
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
|
||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
@@ -53,47 +97,8 @@ 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',
|
||||
},
|
||||
}),
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
@@ -159,47 +164,8 @@ 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',
|
||||
},
|
||||
}),
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
@@ -299,47 +265,10 @@ 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',
|
||||
},
|
||||
}),
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
saveControllerPreference: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
@@ -400,47 +329,8 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
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',
|
||||
},
|
||||
}),
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async (update) => {
|
||||
await Promise.resolve();
|
||||
controllerSaves.push(update);
|
||||
@@ -467,16 +357,16 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
await assert.rejects(async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
}, /Invalid controller preference payload/);
|
||||
await saveHandler!(
|
||||
{},
|
||||
{
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'Pad 1',
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerSaves, [
|
||||
{
|
||||
@@ -486,6 +376,85 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers awaits saveControllerConfig through request-response IPC', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const controllerConfigSaves: 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: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async (update) => {
|
||||
await Promise.resolve();
|
||||
controllerConfigSaves.push(update);
|
||||
},
|
||||
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.saveControllerConfig);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { bindings: { toggleLookup: { kind: 'button', buttonIndex: -1 } } });
|
||||
},
|
||||
/Invalid controller config payload/,
|
||||
);
|
||||
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-2',
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerConfigSaves, [
|
||||
{
|
||||
preferredGamepadId: 'pad-2',
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(
|
||||
@@ -508,47 +477,8 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
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',
|
||||
},
|
||||
}),
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
@@ -570,10 +500,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
);
|
||||
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
},
|
||||
/Invalid controller preference payload/,
|
||||
);
|
||||
await assert.rejects(async () => {
|
||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||
}, /Invalid controller preference payload/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import electron from 'electron';
|
||||
import type { IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
RuntimeOptionId,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
parseControllerConfigUpdate,
|
||||
parseControllerPreferenceUpdate,
|
||||
parseOptionalForwardingOptions,
|
||||
parseOverlayHostedModal,
|
||||
@@ -49,6 +51,7 @@ export interface IpcServiceDeps {
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
@@ -114,6 +117,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
@@ -167,6 +171,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
getKeybindings: options.getKeybindings,
|
||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerConfig: options.saveControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
getSecondarySubMode: options.getSecondarySubMode,
|
||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||
@@ -265,12 +270,23 @@ 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);
|
||||
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.command.saveControllerConfig, async (_event: unknown, update: unknown) => {
|
||||
const parsedUpdate = parseControllerConfigUpdate(update);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller preference payload');
|
||||
throw new Error('Invalid controller config payload');
|
||||
}
|
||||
await deps.saveControllerPreference(parsedUpdate);
|
||||
await deps.saveControllerConfig(parsedUpdate);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
|
||||
@@ -55,8 +55,9 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
|
||||
|
||||
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'),
|
||||
const resolved = resolveExternalYomitanExtensionPath(
|
||||
profilePath,
|
||||
(candidate) => candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
|
||||
);
|
||||
|
||||
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
|
||||
|
||||
@@ -25,9 +25,7 @@ export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDe
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
}
|
||||
|
||||
export function clearYomitanExtensionRuntimeState(
|
||||
deps: YomitanExtensionRuntimeStateDeps,
|
||||
): void {
|
||||
export function clearYomitanExtensionRuntimeState(deps: YomitanExtensionRuntimeStateDeps): void {
|
||||
clearYomitanParserRuntimeState(deps);
|
||||
deps.setYomitanExtension(null);
|
||||
deps.setYomitanSession(null);
|
||||
|
||||
30
src/main.ts
30
src/main.ts
@@ -30,6 +30,7 @@ import {
|
||||
dialog,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
@@ -694,7 +695,8 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
});
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
isExternalYomitanConfigured: () =>
|
||||
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
detectPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
@@ -3117,8 +3119,7 @@ function initializeOverlayRuntime(): void {
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
const message =
|
||||
'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
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.',
|
||||
);
|
||||
@@ -3456,6 +3457,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getControllerConfig: () => getResolvedConfig().controller,
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = configService.getRawConfig();
|
||||
configService.patchRawConfig({
|
||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
||||
});
|
||||
},
|
||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||
configService.patchRawConfig({
|
||||
controller: {
|
||||
@@ -3572,11 +3579,11 @@ 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,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
@@ -3704,12 +3711,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
openYomitanSettingsWindow: ({
|
||||
yomitanExt,
|
||||
getExistingWindow,
|
||||
setWindow,
|
||||
yomitanSession,
|
||||
}) => {
|
||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: yomitanExt as Extension,
|
||||
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
||||
|
||||
54
src/main/controller-config-update.test.ts
Normal file
54
src/main/controller-config-update.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
preferredGamepadId: 'pad-1',
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'axis', axisIndex: 4, direction: 'positive' },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 11 });
|
||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'button', buttonIndex: 1 });
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate merges buttonIndices while replacing only updated binding leaves', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
buttonIndices: {
|
||||
buttonSouth: 9,
|
||||
},
|
||||
bindings: {
|
||||
closeLookup: { kind: 'none' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(next.buttonIndices, {
|
||||
select: 6,
|
||||
buttonSouth: 9,
|
||||
});
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
|
||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
|
||||
});
|
||||
38
src/main/controller-config-update.ts
Normal file
38
src/main/controller-config-update.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ControllerConfigUpdate, RawConfig } from '../types';
|
||||
|
||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
||||
|
||||
export function applyControllerConfigUpdate(
|
||||
currentController: RawConfig['controller'] | undefined,
|
||||
update: ControllerConfigUpdate,
|
||||
): RawControllerConfig {
|
||||
const nextController: RawControllerConfig = {
|
||||
...(currentController ?? {}),
|
||||
...update,
|
||||
};
|
||||
|
||||
if (currentController?.buttonIndices || update.buttonIndices) {
|
||||
nextController.buttonIndices = {
|
||||
...(currentController?.buttonIndices ?? {}),
|
||||
...(update.buttonIndices ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (currentController?.bindings || update.bindings) {
|
||||
const nextBindings: RawControllerBindings = {
|
||||
...(currentController?.bindings ?? {}),
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
|
||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||
>) {
|
||||
if (value === undefined) continue;
|
||||
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
nextController.bindings = nextBindings;
|
||||
}
|
||||
|
||||
return nextController;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||
@@ -216,6 +217,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getKeybindings: params.getKeybindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
|
||||
@@ -52,6 +52,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover' as never,
|
||||
getMpvClient: () => null,
|
||||
|
||||
@@ -279,7 +279,11 @@ export function createFirstRunSetupService(deps: {
|
||||
});
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
!(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied)
|
||||
!(
|
||||
state.yomitanSetupMode === 'external' &&
|
||||
!externalYomitanConfigured &&
|
||||
!yomitanSetupSatisfied
|
||||
)
|
||||
) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as path from 'path';
|
||||
|
||||
function redactSkippedYomitanWriteValue(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
actionName:
|
||||
| 'importYomitanDictionary'
|
||||
| 'deleteYomitanDictionary'
|
||||
| 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
const trimmed = rawValue.trim();
|
||||
@@ -18,7 +21,10 @@ function redactSkippedYomitanWriteValue(
|
||||
}
|
||||
|
||||
export function formatSkippedYomitanWriteAction(
|
||||
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||
actionName:
|
||||
| 'importYomitanDictionary'
|
||||
| 'deleteYomitanDictionary'
|
||||
| 'upsertYomitanDictionarySettings',
|
||||
rawValue: string,
|
||||
): string {
|
||||
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
||||
|
||||
@@ -9,7 +9,11 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
|
||||
openYomitanSettingsWindow: ({
|
||||
getExistingWindow,
|
||||
setWindow,
|
||||
yomitanSession: forwardedSession,
|
||||
}) => {
|
||||
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
||||
const current = getExistingWindow();
|
||||
if (!current) {
|
||||
@@ -54,5 +58,7 @@ test('yomitan settings runtime warns and does not open when no yomitan session i
|
||||
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, [
|
||||
'warn:Unable to open Yomitan settings: Yomitan session is unavailable.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
} from './types';
|
||||
@@ -209,6 +210,8 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||
saveControllerConfig: (update: ControllerConfigUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update),
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
|
||||
|
||||
|
||||
@@ -67,6 +67,25 @@ test('windows release workflow publishes unsigned artifacts directly without Sig
|
||||
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
|
||||
});
|
||||
|
||||
test('release workflow publishes subminer-bin to AUR from tagged release artifacts', () => {
|
||||
assert.match(releaseWorkflow, /aur-publish:/);
|
||||
assert.match(releaseWorkflow, /needs:\s*\[release\]/);
|
||||
assert.match(releaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
||||
assert.match(releaseWorkflow, /ssh:\/\/aur@aur\.archlinux\.org\/subminer-bin\.git/);
|
||||
assert.match(releaseWorkflow, /scripts\/update-aur-package\.sh/);
|
||||
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/);
|
||||
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
|
||||
assert.doesNotMatch(
|
||||
releaseWorkflow,
|
||||
/SubMiner-\$\{\{ steps\.version\.outputs\.VERSION \}\}\.AppImage/,
|
||||
);
|
||||
assert.doesNotMatch(releaseWorkflow, /Install makepkg/);
|
||||
});
|
||||
|
||||
test('release workflow skips empty AUR sync commits', () => {
|
||||
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
|
||||
});
|
||||
|
||||
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
|
||||
assert.match(
|
||||
makefile,
|
||||
|
||||
@@ -25,20 +25,17 @@ test('controller status indicator shows once when a controller is first detected
|
||||
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);
|
||||
},
|
||||
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: [],
|
||||
@@ -78,13 +75,10 @@ test('controller status indicator announces newly detected controllers after sta
|
||||
classList: createClassList(['hidden']),
|
||||
};
|
||||
|
||||
const indicator = createControllerStatusIndicator(
|
||||
{ controllerStatusToast: toast } as never,
|
||||
{
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
);
|
||||
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
|
||||
setTimeout: () => 1 as never,
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
|
||||
indicator.update({
|
||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||
|
||||
@@ -58,7 +58,9 @@ export function createControllerStatusIndicator(
|
||||
(device) => device.id === snapshot.activeGamepadId,
|
||||
);
|
||||
const announcedDevice =
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
|
||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ??
|
||||
newDevices[0] ??
|
||||
activeDevice;
|
||||
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
||||
}
|
||||
|
||||
|
||||
129
src/renderer/handlers/controller-binding-capture.test.ts
Normal file
129
src/renderer/handlers/controller-binding-capture.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createControllerBindingCapture } from './controller-binding-capture.js';
|
||||
|
||||
function createSnapshot(
|
||||
overrides: {
|
||||
axes?: number[];
|
||||
buttons?: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
axes: overrides.axes ?? [0, 0, 0, 0, 0],
|
||||
buttons:
|
||||
overrides.buttons ??
|
||||
Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
test('controller binding capture waits for neutral-to-active button edge', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
const heldButtons = createSnapshot({
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons);
|
||||
|
||||
assert.equal(capture.poll(heldButtons), null);
|
||||
|
||||
const neutralButtons = createSnapshot();
|
||||
assert.equal(capture.poll(neutralButtons), null);
|
||||
|
||||
const freshPress = createSnapshot({
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
});
|
||||
assert.deepEqual(capture.poll(freshPress), {
|
||||
actionId: 'toggleLookup',
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'button', buttonIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test('controller binding capture records fresh axis direction for discrete learn mode', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'closeLookup', bindingType: 'discrete' }, createSnapshot());
|
||||
|
||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, -0.8] })), {
|
||||
actionId: 'closeLookup',
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'axis', axisIndex: 3, direction: 'negative' },
|
||||
});
|
||||
});
|
||||
|
||||
test('controller binding capture ignores analog drift inside deadzone', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.3,
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot());
|
||||
|
||||
assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null);
|
||||
assert.equal(capture.isArmed(), true);
|
||||
});
|
||||
|
||||
test('controller binding capture emits axis binding for continuous learn mode', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
capture.arm(
|
||||
{
|
||||
actionId: 'leftStickHorizontal',
|
||||
bindingType: 'axis',
|
||||
dpadFallback: 'horizontal',
|
||||
},
|
||||
createSnapshot(),
|
||||
);
|
||||
|
||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, 0.9] })), {
|
||||
actionId: 'leftStickHorizontal',
|
||||
bindingType: 'axis',
|
||||
binding: { kind: 'axis', axisIndex: 3, dpadFallback: 'horizontal' },
|
||||
});
|
||||
});
|
||||
|
||||
test('controller binding capture ignores button presses for continuous learn mode', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
capture.arm(
|
||||
{
|
||||
actionId: 'leftStickHorizontal',
|
||||
bindingType: 'axis',
|
||||
dpadFallback: 'horizontal',
|
||||
},
|
||||
createSnapshot(),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
capture.poll(
|
||||
createSnapshot({
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0.75, 0, 0] })), {
|
||||
actionId: 'leftStickHorizontal',
|
||||
bindingType: 'axis',
|
||||
binding: { kind: 'axis', axisIndex: 2, dpadFallback: 'horizontal' },
|
||||
});
|
||||
});
|
||||
194
src/renderer/handlers/controller-binding-capture.ts
Normal file
194
src/renderer/handlers/controller-binding-capture.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
pressed?: boolean;
|
||||
touched?: boolean;
|
||||
};
|
||||
|
||||
type ControllerBindingCaptureSnapshot = {
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
|
||||
type ControllerBindingCaptureTarget =
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'discrete';
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'axis';
|
||||
dpadFallback: ControllerDpadFallback;
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'dpad';
|
||||
};
|
||||
|
||||
type ControllerBindingCaptureResult =
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'discrete';
|
||||
binding: ResolvedControllerDiscreteBinding;
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'axis';
|
||||
binding: ResolvedControllerAxisBinding;
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'dpad';
|
||||
dpadDirection: ControllerDpadFallback;
|
||||
};
|
||||
|
||||
function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean {
|
||||
if (!button) return false;
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function getAxisDirection(
|
||||
value: number | undefined,
|
||||
activationThreshold: number,
|
||||
): 'negative' | 'positive' | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
if (Math.abs(value) < activationThreshold) return null;
|
||||
return value > 0 ? 'positive' : 'negative';
|
||||
}
|
||||
|
||||
const DPAD_BUTTON_INDICES = [12, 13, 14, 15] as const;
|
||||
|
||||
export function createControllerBindingCapture(options: {
|
||||
triggerDeadzone: number;
|
||||
stickDeadzone: number;
|
||||
}) {
|
||||
let target: ControllerBindingCaptureTarget | null = null;
|
||||
const blockedButtons = new Set<number>();
|
||||
const blockedAxisDirections = new Set<string>();
|
||||
|
||||
function resetBlockedState(snapshot: ControllerBindingCaptureSnapshot): void {
|
||||
blockedButtons.clear();
|
||||
blockedAxisDirections.clear();
|
||||
|
||||
snapshot.buttons.forEach((button, index) => {
|
||||
if (isActiveButton(button, options.triggerDeadzone)) {
|
||||
blockedButtons.add(index);
|
||||
}
|
||||
});
|
||||
|
||||
const activationThreshold = Math.max(options.stickDeadzone, 0.55);
|
||||
snapshot.axes.forEach((value, index) => {
|
||||
const direction = getAxisDirection(value, activationThreshold);
|
||||
if (direction) {
|
||||
blockedAxisDirections.add(`${index}:${direction}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void {
|
||||
target = nextTarget;
|
||||
resetBlockedState(snapshot);
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
target = null;
|
||||
blockedButtons.clear();
|
||||
blockedAxisDirections.clear();
|
||||
}
|
||||
|
||||
function poll(snapshot: ControllerBindingCaptureSnapshot): ControllerBindingCaptureResult | null {
|
||||
if (!target) return null;
|
||||
|
||||
snapshot.buttons.forEach((button, index) => {
|
||||
if (!isActiveButton(button, options.triggerDeadzone)) {
|
||||
blockedButtons.delete(index);
|
||||
}
|
||||
});
|
||||
|
||||
const activationThreshold = Math.max(options.stickDeadzone, 0.55);
|
||||
snapshot.axes.forEach((value, index) => {
|
||||
const negativeKey = `${index}:negative`;
|
||||
const positiveKey = `${index}:positive`;
|
||||
if (getAxisDirection(value, activationThreshold) === null) {
|
||||
blockedAxisDirections.delete(negativeKey);
|
||||
blockedAxisDirections.delete(positiveKey);
|
||||
}
|
||||
});
|
||||
|
||||
// D-pad capture: only respond to d-pad buttons (12-15)
|
||||
if (target.bindingType === 'dpad') {
|
||||
for (const index of DPAD_BUTTON_INDICES) {
|
||||
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
|
||||
if (blockedButtons.has(index)) continue;
|
||||
|
||||
const dpadDirection: ControllerDpadFallback =
|
||||
index === 12 || index === 13 ? 'vertical' : 'horizontal';
|
||||
cancel();
|
||||
return {
|
||||
actionId: target.actionId,
|
||||
bindingType: 'dpad' as const,
|
||||
dpadDirection,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// After dpad early-return, only 'discrete' | 'axis' remain
|
||||
const narrowedTarget: Extract<ControllerBindingCaptureTarget, { bindingType: 'discrete' | 'axis' }> = target;
|
||||
|
||||
for (let index = 0; index < snapshot.buttons.length; index += 1) {
|
||||
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
|
||||
if (blockedButtons.has(index)) continue;
|
||||
if (narrowedTarget.bindingType === 'axis') continue;
|
||||
|
||||
const result: ControllerBindingCaptureResult = {
|
||||
actionId: narrowedTarget.actionId,
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'button', buttonIndex: index },
|
||||
};
|
||||
cancel();
|
||||
return result;
|
||||
}
|
||||
|
||||
for (let index = 0; index < snapshot.axes.length; index += 1) {
|
||||
const direction = getAxisDirection(snapshot.axes[index], activationThreshold);
|
||||
if (!direction) continue;
|
||||
const directionKey = `${index}:${direction}`;
|
||||
if (blockedAxisDirections.has(directionKey)) continue;
|
||||
|
||||
const result: ControllerBindingCaptureResult =
|
||||
narrowedTarget.bindingType === 'discrete'
|
||||
? {
|
||||
actionId: narrowedTarget.actionId,
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'axis', axisIndex: index, direction },
|
||||
}
|
||||
: {
|
||||
actionId: narrowedTarget.actionId,
|
||||
bindingType: 'axis',
|
||||
binding: {
|
||||
kind: 'axis',
|
||||
axisIndex: index,
|
||||
dpadFallback: narrowedTarget.dpadFallback,
|
||||
},
|
||||
};
|
||||
cancel();
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
arm,
|
||||
cancel,
|
||||
isArmed: (): boolean => target !== null,
|
||||
getTargetActionId: (): string | null => target?.actionId ?? null,
|
||||
poll,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,20 @@ type TestGamepad = {
|
||||
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
||||
};
|
||||
|
||||
const DEFAULT_BUTTON_INDICES = {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
} satisfies ResolvedControllerConfig['buttonIndices'];
|
||||
|
||||
function createGamepad(
|
||||
id: string,
|
||||
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
|
||||
@@ -35,12 +49,15 @@ function createGamepad(
|
||||
|
||||
function createControllerConfig(
|
||||
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
|
||||
bindings?: Partial<ResolvedControllerConfig['bindings']>;
|
||||
bindings?: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>;
|
||||
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
||||
} = {},
|
||||
): ResolvedControllerConfig {
|
||||
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
|
||||
overrides;
|
||||
const {
|
||||
bindings: bindingOverrides,
|
||||
buttonIndices: buttonIndexOverrides,
|
||||
...restOverrides
|
||||
} = overrides;
|
||||
return {
|
||||
enabled: true,
|
||||
preferredGamepadId: '',
|
||||
@@ -54,43 +71,100 @@ function createControllerConfig(
|
||||
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,
|
||||
...DEFAULT_BUTTON_INDICES,
|
||||
...(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 ?? {}),
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
...normalizeBindingOverrides(bindingOverrides ?? {}, {
|
||||
...DEFAULT_BUTTON_INDICES,
|
||||
...(buttonIndexOverrides ?? {}),
|
||||
}),
|
||||
},
|
||||
...restOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBindingOverrides(
|
||||
overrides: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>,
|
||||
buttonIndices: ResolvedControllerConfig['buttonIndices'],
|
||||
): Partial<ResolvedControllerConfig['bindings']> {
|
||||
const legacyButtonIndices = {
|
||||
select: buttonIndices.select,
|
||||
buttonSouth: buttonIndices.buttonSouth,
|
||||
buttonEast: buttonIndices.buttonEast,
|
||||
buttonWest: buttonIndices.buttonWest,
|
||||
buttonNorth: buttonIndices.buttonNorth,
|
||||
leftShoulder: buttonIndices.leftShoulder,
|
||||
rightShoulder: buttonIndices.rightShoulder,
|
||||
leftStickPress: buttonIndices.leftStickPress,
|
||||
rightStickPress: buttonIndices.rightStickPress,
|
||||
leftTrigger: buttonIndices.leftTrigger,
|
||||
rightTrigger: buttonIndices.rightTrigger,
|
||||
} as const;
|
||||
const legacyAxisIndices = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
} as const;
|
||||
const axisFallbackByKey = {
|
||||
leftStickHorizontal: 'horizontal',
|
||||
leftStickVertical: 'vertical',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const;
|
||||
|
||||
const normalized: Partial<ResolvedControllerConfig['bindings']> = {};
|
||||
for (const [key, value] of Object.entries(overrides) as Array<
|
||||
[keyof ResolvedControllerConfig['bindings'], unknown]
|
||||
>) {
|
||||
if (typeof value === 'string') {
|
||||
if (value === 'none') {
|
||||
normalized[key] = { kind: 'none' } as never;
|
||||
continue;
|
||||
}
|
||||
if (value in legacyButtonIndices) {
|
||||
normalized[key] = {
|
||||
kind: 'button',
|
||||
buttonIndex: legacyButtonIndices[value as keyof typeof legacyButtonIndices],
|
||||
} as never;
|
||||
continue;
|
||||
}
|
||||
if (value in legacyAxisIndices) {
|
||||
normalized[key] = {
|
||||
kind: 'axis',
|
||||
axisIndex: legacyAxisIndices[value as keyof typeof legacyAxisIndices],
|
||||
dpadFallback: axisFallbackByKey[key as keyof typeof axisFallbackByKey] ?? 'none',
|
||||
} as never;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
normalized[key] = value as never;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 })],
|
||||
getGamepads: () => [
|
||||
null,
|
||||
createGamepad('pad-2', { index: 1 }),
|
||||
createGamepad('pad-3', { index: 2 }),
|
||||
],
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getLookupWindowOpen: () => false,
|
||||
@@ -177,6 +251,82 @@ test('gamepad controller allows keyboard-mode toggle while other actions stay ga
|
||||
assert.deepEqual(calls, ['toggle-keyboard-mode']);
|
||||
});
|
||||
|
||||
test('gamepad controller re-evaluates interaction gating after toggling keyboard mode', () => {
|
||||
const calls: string[] = [];
|
||||
let keyboardModeEnabled = true;
|
||||
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: () => keyboardModeEnabled,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {
|
||||
calls.push('toggle-keyboard-mode');
|
||||
keyboardModeEnabled = false;
|
||||
},
|
||||
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 resets edge state when active controller changes', () => {
|
||||
const calls: string[] = [];
|
||||
let currentGamepads = [
|
||||
createGamepad('pad-1', {
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
}),
|
||||
];
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => currentGamepads,
|
||||
getConfig: () => createControllerConfig(),
|
||||
getKeyboardModeEnabled: () => true,
|
||||
getLookupWindowOpen: () => false,
|
||||
getInteractionBlocked: () => false,
|
||||
toggleKeyboardMode: () => {},
|
||||
toggleLookup: () => calls.push('toggle-lookup'),
|
||||
closeLookup: () => {},
|
||||
moveSelection: () => {},
|
||||
mineCard: () => {},
|
||||
quitMpv: () => {},
|
||||
previousAudio: () => {},
|
||||
nextAudio: () => {},
|
||||
playCurrentAudio: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
onState: () => {},
|
||||
});
|
||||
|
||||
controller.poll(0);
|
||||
currentGamepads = [
|
||||
createGamepad('pad-2', {
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
}),
|
||||
];
|
||||
controller.poll(50);
|
||||
|
||||
assert.deepEqual(calls, ['toggle-lookup', 'toggle-lookup']);
|
||||
});
|
||||
|
||||
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 }));
|
||||
@@ -310,13 +460,12 @@ test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigati
|
||||
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,
|
||||
}),
|
||||
],
|
||||
getGamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||
buttons,
|
||||
}),
|
||||
],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
bindings: {
|
||||
@@ -352,7 +501,10 @@ test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigati
|
||||
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.deepEqual(
|
||||
scrollCalls.map((value) => Math.round(value)),
|
||||
[-67],
|
||||
);
|
||||
assert.equal(calls.includes('jump:160'), true);
|
||||
});
|
||||
|
||||
@@ -492,7 +644,10 @@ test('gamepad controller maps d-pad left/right to selection and d-pad up/down to
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
assert.deepEqual(
|
||||
scrollCalls.map((value) => Math.round(value)),
|
||||
[-90],
|
||||
);
|
||||
});
|
||||
|
||||
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
||||
@@ -524,7 +679,10 @@ test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll',
|
||||
controller.poll(100);
|
||||
|
||||
assert.deepEqual(selectionCalls, [1]);
|
||||
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||
assert.deepEqual(
|
||||
scrollCalls.map((value) => Math.round(value)),
|
||||
[-90],
|
||||
);
|
||||
});
|
||||
|
||||
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
||||
@@ -607,6 +765,46 @@ test('gamepad controller trigger digital mode uses pressed state only', () => {
|
||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||
});
|
||||
|
||||
test('gamepad controller digital trigger bindings ignore analog-only trigger values', () => {
|
||||
const calls: string[] = [];
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
buttons[6] = { value: 0.9, pressed: false, touched: true };
|
||||
buttons[7] = { value: 0.9, pressed: false, touched: true };
|
||||
|
||||
const controller = createGamepadController({
|
||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
getConfig: () =>
|
||||
createControllerConfig({
|
||||
triggerInputMode: 'digital',
|
||||
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, []);
|
||||
});
|
||||
|
||||
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 }));
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerButtonBinding,
|
||||
ControllerDeviceInfo,
|
||||
ControllerRuntimeSnapshot,
|
||||
ControllerTriggerInputMode,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
@@ -50,69 +49,18 @@ type HoldState = {
|
||||
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,
|
||||
@@ -121,23 +69,18 @@ function normalizeRawButtonState(
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function normalizeTriggerState(
|
||||
function resolveTriggerBindingPressed(
|
||||
button: ControllerButtonState | undefined,
|
||||
mode: ControllerTriggerInputMode,
|
||||
triggerDeadzone: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
if (mode === 'digital') {
|
||||
if (config.triggerInputMode === 'digital') {
|
||||
return Boolean(button.pressed);
|
||||
}
|
||||
if (mode === 'analog') {
|
||||
return button.value >= triggerDeadzone;
|
||||
if (config.triggerInputMode === 'analog') {
|
||||
return button.value >= config.triggerDeadzone;
|
||||
}
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
|
||||
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
|
||||
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
|
||||
return normalizeRawButtonState(button, config.triggerDeadzone);
|
||||
}
|
||||
|
||||
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
||||
@@ -159,10 +102,7 @@ function resolveDpadValue(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadAxisValue(
|
||||
gamepad: GamepadLike,
|
||||
axisIndex: number,
|
||||
): number {
|
||||
function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
|
||||
const value = resolveGamepadAxis(gamepad, axisIndex);
|
||||
if (Math.abs(value) < 0.5) {
|
||||
return 0;
|
||||
@@ -175,7 +115,12 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
}
|
||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
|
||||
return resolveDpadValue(
|
||||
gamepad,
|
||||
DPAD_BUTTON_INDEX.left,
|
||||
DPAD_BUTTON_INDEX.right,
|
||||
triggerDeadzone,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
@@ -201,7 +146,12 @@ function createHoldState(): HoldState {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
|
||||
function shouldFireHeldAction(
|
||||
state: HoldState,
|
||||
now: number,
|
||||
repeatDelayMs: number,
|
||||
repeatIntervalMs: number,
|
||||
): boolean {
|
||||
if (!state.initialFired) {
|
||||
state.initialFired = true;
|
||||
state.lastFireAt = now;
|
||||
@@ -244,8 +194,57 @@ function syncHeldActionBlocked(
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
function resolveDiscreteBindingPressed(
|
||||
gamepad: GamepadLike,
|
||||
binding: ResolvedControllerDiscreteBinding,
|
||||
config: ResolvedControllerConfig,
|
||||
): boolean {
|
||||
if (binding.kind === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (binding.kind === 'button') {
|
||||
const button = gamepad.buttons[binding.buttonIndex];
|
||||
const isTriggerBinding =
|
||||
binding.buttonIndex === config.buttonIndices.leftTrigger ||
|
||||
binding.buttonIndex === config.buttonIndices.rightTrigger;
|
||||
return isTriggerBinding
|
||||
? resolveTriggerBindingPressed(button, config)
|
||||
: normalizeRawButtonState(button, config.triggerDeadzone);
|
||||
}
|
||||
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex);
|
||||
return binding.direction === 'positive'
|
||||
? axisValue >= activationThreshold
|
||||
: axisValue <= -activationThreshold;
|
||||
}
|
||||
|
||||
function resolveAxisBindingValue(
|
||||
gamepad: GamepadLike,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
triggerDeadzone: number,
|
||||
activationThreshold: number,
|
||||
): number {
|
||||
if (binding.kind === 'none') {
|
||||
return 0;
|
||||
}
|
||||
const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex);
|
||||
if (Math.abs(axisValue) >= activationThreshold) {
|
||||
return axisValue;
|
||||
}
|
||||
|
||||
if (binding.dpadFallback === 'horizontal') {
|
||||
return resolveDpadHorizontalValue(gamepad, triggerDeadzone);
|
||||
}
|
||||
if (binding.dpadFallback === 'vertical') {
|
||||
return resolveDpadVerticalValue(gamepad, triggerDeadzone);
|
||||
}
|
||||
return axisValue;
|
||||
}
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
||||
let previousActions = new Map<string, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
let jumpHold = createHoldState();
|
||||
let activeGamepadId: string | null = null;
|
||||
@@ -290,26 +289,22 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonEdge(
|
||||
binding: ControllerButtonBinding,
|
||||
isPressed: boolean,
|
||||
function handleActionEdge(
|
||||
actionKey: string,
|
||||
binding: ResolvedControllerDiscreteBinding,
|
||||
activeGamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
action: () => void,
|
||||
): void {
|
||||
if (binding === 'none') {
|
||||
return;
|
||||
}
|
||||
const wasPressed = previousButtons.get(binding) ?? false;
|
||||
previousButtons.set(binding, isPressed);
|
||||
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
|
||||
const wasPressed = previousActions.get(actionKey) ?? false;
|
||||
previousActions.set(actionKey, isPressed);
|
||||
if (!wasPressed && isPressed) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(selectionHold);
|
||||
@@ -327,11 +322,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpAxis(
|
||||
value: number,
|
||||
now: number,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
function handleJumpAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
resetHeldAction(jumpHold);
|
||||
@@ -354,47 +345,42 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
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,
|
||||
]);
|
||||
const discreteActions = [
|
||||
['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode],
|
||||
['toggleLookup', config.bindings.toggleLookup],
|
||||
['closeLookup', config.bindings.closeLookup],
|
||||
['mineCard', config.bindings.mineCard],
|
||||
['quitMpv', config.bindings.quitMpv],
|
||||
['previousAudio', config.bindings.previousAudio],
|
||||
['nextAudio', config.bindings.nextAudio],
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['toggleMpvPause', config.bindings.toggleMpvPause],
|
||||
] as const;
|
||||
|
||||
for (const binding of buttonBindings) {
|
||||
if (binding === 'none') continue;
|
||||
previousButtons.set(
|
||||
binding,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
binding,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
);
|
||||
for (const [actionKey, binding] of discreteActions) {
|
||||
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
|
||||
}
|
||||
|
||||
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));
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
const selectionValue = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.leftStickHorizontal,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
);
|
||||
syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
syncHeldActionBlocked(
|
||||
jumpHold,
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.rightStickVertical,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
),
|
||||
now,
|
||||
Math.max(config.stickDeadzone, 0.55),
|
||||
activationThreshold,
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
@@ -407,131 +393,102 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
const config = options.getConfig();
|
||||
const connectedGamepads = getConnectedGamepads();
|
||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||
const previousActiveGamepadId = activeGamepadId;
|
||||
publishState(connectedGamepads, activeGamepad);
|
||||
|
||||
if (!activeGamepad) {
|
||||
previousButtons = new Map();
|
||||
previousActions = new Map();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
lastPollAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionAllowed =
|
||||
config.enabled &&
|
||||
options.getKeyboardModeEnabled() &&
|
||||
!options.getInteractionBlocked();
|
||||
if (activeGamepad.id !== previousActiveGamepadId) {
|
||||
previousActions = new Map();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
let interactionAllowed =
|
||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||
if (config.enabled) {
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'toggleKeyboardOnlyMode',
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
options.toggleKeyboardMode,
|
||||
);
|
||||
}
|
||||
|
||||
interactionAllowed =
|
||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||
|
||||
if (!interactionAllowed) {
|
||||
syncBlockedInteractionState(activeGamepad, config, now);
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'toggleLookup',
|
||||
config.bindings.toggleLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
options.toggleLookup,
|
||||
);
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'closeLookup',
|
||||
config.bindings.closeLookup,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.closeLookup,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
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,
|
||||
);
|
||||
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
|
||||
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
|
||||
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'previousAudio',
|
||||
config.bindings.previousAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.previousAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
options.previousAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'nextAudio',
|
||||
config.bindings.nextAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.nextAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
options.nextAudio,
|
||||
);
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'playCurrentAudio',
|
||||
config.bindings.playCurrentAudio,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.playCurrentAudio,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
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);
|
||||
}
|
||||
const primaryScroll = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.leftStickVertical,
|
||||
config.triggerDeadzone,
|
||||
config.stickDeadzone,
|
||||
);
|
||||
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
}
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.rightStickVertical,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
@@ -539,26 +496,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
handleButtonEdge(
|
||||
handleActionEdge(
|
||||
'toggleMpvPause',
|
||||
config.bindings.toggleMpvPause,
|
||||
normalizeButtonState(
|
||||
activeGamepad,
|
||||
config,
|
||||
config.bindings.toggleMpvPause,
|
||||
config.triggerInputMode,
|
||||
config.triggerDeadzone,
|
||||
),
|
||||
activeGamepad,
|
||||
config,
|
||||
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);
|
||||
})(),
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.leftStickHorizontal,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
),
|
||||
now,
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -3,10 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import {
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
} from '../yomitan-popup.js';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
type CommandEventDetail = {
|
||||
type?: string;
|
||||
@@ -478,14 +475,11 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
|
||||
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 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(testGlobals.commandEvents.slice(-3), [
|
||||
{ type: 'playCurrentAudio' },
|
||||
{ type: 'cycleAudioSource', direction: 1 },
|
||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -531,7 +525,8 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
|
||||
});
|
||||
|
||||
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
|
||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
@@ -187,7 +187,9 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||
function clearKeyboardSelectedWordClasses(
|
||||
wordNodes: HTMLElement[] = getSubtitleWordNodes(),
|
||||
): void {
|
||||
for (const wordNode of wordNodes) {
|
||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
}
|
||||
|
||||
@@ -201,14 +201,16 @@
|
||||
<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>
|
||||
<div class="modal-title">Controller Configuration</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>
|
||||
<label class="controller-select-field">
|
||||
<span>Preferred Controller</span>
|
||||
<select id="controllerSelectPicker"></select>
|
||||
</label>
|
||||
<div id="controllerSelectSummary" class="controller-select-summary"></div>
|
||||
<div id="controllerConfigList" class="controller-config-list"></div>
|
||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||
<div class="subsync-footer">
|
||||
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
|
||||
|
||||
146
src/renderer/modals/controller-config-form.test.ts
Normal file
146
src/renderer/modals/controller-config-form.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createControllerConfigForm } from './controller-config-form.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),
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeElement() {
|
||||
const attributes = new Map<string, string>();
|
||||
const el = {
|
||||
className: '',
|
||||
textContent: '',
|
||||
_innerHTML: '',
|
||||
value: '',
|
||||
disabled: false,
|
||||
selected: false,
|
||||
type: '',
|
||||
children: [] as any[],
|
||||
listeners: new Map<string, Array<(e?: any) => void>>(),
|
||||
classList: createClassList(),
|
||||
appendChild(child: any) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
addEventListener(type: string, listener: (e?: any) => void) {
|
||||
const existing = this.listeners.get(type) ?? [];
|
||||
existing.push(listener);
|
||||
this.listeners.set(type, existing);
|
||||
},
|
||||
dispatch(type: string) {
|
||||
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(fakeEvent);
|
||||
}
|
||||
},
|
||||
setAttribute(name: string, value: string) {
|
||||
attributes.set(name, value);
|
||||
},
|
||||
getAttribute(name: string) {
|
||||
return attributes.get(name) ?? null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() {
|
||||
return el._innerHTML;
|
||||
},
|
||||
set(v: string) {
|
||||
el._innerHTML = v;
|
||||
if (v === '') el.children.length = 0;
|
||||
},
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
|
||||
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createFakeElement(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const calls: string[] = [];
|
||||
const container = createFakeElement();
|
||||
const form = createControllerConfigForm({
|
||||
container: container as never,
|
||||
getBindings: () =>
|
||||
({
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
}) as never,
|
||||
getLearningActionId: () => 'toggleLookup',
|
||||
getDpadLearningActionId: () => null,
|
||||
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
|
||||
onClear: (actionId) => calls.push(`clear:${actionId}`),
|
||||
onReset: (actionId) => calls.push(`reset:${actionId}`),
|
||||
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
|
||||
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
|
||||
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
|
||||
});
|
||||
|
||||
form.render();
|
||||
|
||||
// In the new compact list layout, children are:
|
||||
// [0] group header, [1] first binding row (auto-expanded because learning), [2] edit panel, [3] next row, ...
|
||||
const firstRow = container.children[1];
|
||||
assert.equal(firstRow.classList.contains('expanded'), true);
|
||||
|
||||
// After expanding, the edit panel is inserted after the row:
|
||||
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
|
||||
const editPanel = container.children[2];
|
||||
// editPanel > inner > actions > learnButton
|
||||
const inner = editPanel.children[0];
|
||||
const actions = inner.children[1];
|
||||
const learnButton = actions.children[0];
|
||||
learnButton.dispatch('click');
|
||||
actions.children[1].dispatch('click');
|
||||
actions.children[2].dispatch('click');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'learn:toggleLookup:discrete',
|
||||
'clear:toggleLookup',
|
||||
'reset:toggleLookup',
|
||||
]);
|
||||
} finally {
|
||||
if (previousDocumentDescriptor) {
|
||||
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
}
|
||||
});
|
||||
429
src/renderer/modals/controller-config-form.ts
Normal file
429
src/renderer/modals/controller-config-form.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerBindingActionId = keyof ResolvedControllerConfig['bindings'];
|
||||
|
||||
type ControllerBindingDefinition = {
|
||||
id: ControllerBindingActionId;
|
||||
label: string;
|
||||
group: string;
|
||||
bindingType: 'discrete' | 'axis';
|
||||
defaultBinding: ResolvedControllerConfig['bindings'][ControllerBindingActionId];
|
||||
};
|
||||
|
||||
export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
||||
{
|
||||
id: 'toggleLookup',
|
||||
label: 'Toggle Lookup',
|
||||
group: 'Lookup',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 0 },
|
||||
},
|
||||
{
|
||||
id: 'closeLookup',
|
||||
label: 'Close Lookup',
|
||||
group: 'Lookup',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 1 },
|
||||
},
|
||||
{
|
||||
id: 'mineCard',
|
||||
label: 'Mine Card',
|
||||
group: 'Lookup',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 2 },
|
||||
},
|
||||
{
|
||||
id: 'toggleKeyboardOnlyMode',
|
||||
label: 'Toggle Keyboard-Only Mode',
|
||||
group: 'Playback',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 3 },
|
||||
},
|
||||
{
|
||||
id: 'toggleMpvPause',
|
||||
label: 'Toggle MPV Pause',
|
||||
group: 'Playback',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 9 },
|
||||
},
|
||||
{
|
||||
id: 'quitMpv',
|
||||
label: 'Quit MPV',
|
||||
group: 'Playback',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 6 },
|
||||
},
|
||||
{
|
||||
id: 'previousAudio',
|
||||
label: 'Previous Audio',
|
||||
group: 'Popup Audio',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'nextAudio',
|
||||
label: 'Next Audio',
|
||||
group: 'Popup Audio',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 5 },
|
||||
},
|
||||
{
|
||||
id: 'playCurrentAudio',
|
||||
label: 'Play Current Audio',
|
||||
group: 'Popup Audio',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
{
|
||||
id: 'leftStickHorizontal',
|
||||
label: 'Token Move',
|
||||
group: 'Navigation',
|
||||
bindingType: 'axis',
|
||||
defaultBinding: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
},
|
||||
{
|
||||
id: 'leftStickVertical',
|
||||
label: 'Popup Scroll',
|
||||
group: 'Navigation',
|
||||
bindingType: 'axis',
|
||||
defaultBinding: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
},
|
||||
{
|
||||
id: 'rightStickHorizontal',
|
||||
label: 'Alt Horizontal',
|
||||
group: 'Navigation',
|
||||
bindingType: 'axis',
|
||||
defaultBinding: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'rightStickVertical',
|
||||
label: 'Popup Jump',
|
||||
group: 'Navigation',
|
||||
bindingType: 'axis',
|
||||
defaultBinding: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
];
|
||||
|
||||
export function getControllerBindingDefinition(actionId: ControllerBindingActionId) {
|
||||
return CONTROLLER_BINDING_DEFINITIONS.find((definition) => definition.id === actionId) ?? null;
|
||||
}
|
||||
|
||||
export function getDefaultControllerBinding(actionId: ControllerBindingActionId) {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) {
|
||||
return { kind: 'none' } as const;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(definition.defaultBinding)) as ResolvedControllerConfig['bindings'][ControllerBindingActionId];
|
||||
}
|
||||
|
||||
export function getDefaultDpadFallback(actionId: ControllerBindingActionId): ControllerDpadFallback {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition || definition.defaultBinding.kind !== 'axis') return 'none';
|
||||
const binding = definition.defaultBinding;
|
||||
return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none';
|
||||
}
|
||||
|
||||
const STANDARD_BUTTON_NAMES: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
2: 'X / Square',
|
||||
3: 'Y / Triangle',
|
||||
4: 'LB / L1',
|
||||
5: 'RB / R1',
|
||||
6: 'Back / Select',
|
||||
7: 'Start / Options',
|
||||
8: 'L3 / LS',
|
||||
9: 'R3 / RS',
|
||||
10: 'Left Stick Click',
|
||||
11: 'Right Stick Click',
|
||||
12: 'D-pad Up',
|
||||
13: 'D-pad Down',
|
||||
14: 'D-pad Left',
|
||||
15: 'D-pad Right',
|
||||
16: 'Guide / Home',
|
||||
};
|
||||
|
||||
const STANDARD_AXIS_NAMES: Record<number, string> = {
|
||||
0: 'Left Stick X',
|
||||
1: 'Left Stick Y',
|
||||
2: 'Left Trigger',
|
||||
3: 'Right Stick X',
|
||||
4: 'Right Stick Y',
|
||||
5: 'Right Trigger',
|
||||
};
|
||||
|
||||
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
|
||||
none: 'None',
|
||||
horizontal: 'D-pad \u2194',
|
||||
vertical: 'D-pad \u2195',
|
||||
};
|
||||
|
||||
function getFriendlyButtonName(buttonIndex: number): string {
|
||||
return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`;
|
||||
}
|
||||
|
||||
function getFriendlyAxisName(axisIndex: number): string {
|
||||
return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`;
|
||||
}
|
||||
|
||||
export function formatControllerBindingSummary(
|
||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
||||
): string {
|
||||
if (binding.kind === 'none') {
|
||||
return 'Disabled';
|
||||
}
|
||||
if ('direction' in binding) {
|
||||
return `Axis ${binding.axisIndex} ${binding.direction === 'positive' ? '+' : '-'}`;
|
||||
}
|
||||
if ('buttonIndex' in binding) {
|
||||
return `Button ${binding.buttonIndex}`;
|
||||
}
|
||||
if (binding.dpadFallback === 'none') {
|
||||
return `Axis ${binding.axisIndex}`;
|
||||
}
|
||||
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
|
||||
}
|
||||
|
||||
function formatFriendlyStickLabel(binding: ResolvedControllerAxisBinding): string {
|
||||
if (binding.kind === 'none') return 'None';
|
||||
return getFriendlyAxisName(binding.axisIndex);
|
||||
}
|
||||
|
||||
function formatFriendlyBindingLabel(
|
||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
||||
): string {
|
||||
if (binding.kind === 'none') return 'None';
|
||||
if ('direction' in binding) {
|
||||
const name = getFriendlyAxisName(binding.axisIndex);
|
||||
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
|
||||
}
|
||||
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex);
|
||||
return getFriendlyAxisName(binding.axisIndex);
|
||||
}
|
||||
|
||||
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
|
||||
type ExpandedRowKey = string;
|
||||
|
||||
export function createControllerConfigForm(options: {
|
||||
container: HTMLElement;
|
||||
getBindings: () => ResolvedControllerConfig['bindings'];
|
||||
getLearningActionId: () => ControllerBindingActionId | null;
|
||||
getDpadLearningActionId: () => ControllerBindingActionId | null;
|
||||
onLearn: (actionId: ControllerBindingActionId, bindingType: 'discrete' | 'axis') => void;
|
||||
onClear: (actionId: ControllerBindingActionId) => void;
|
||||
onReset: (actionId: ControllerBindingActionId) => void;
|
||||
onDpadLearn: (actionId: ControllerBindingActionId) => void;
|
||||
onDpadClear: (actionId: ControllerBindingActionId) => void;
|
||||
onDpadReset: (actionId: ControllerBindingActionId) => void;
|
||||
}) {
|
||||
let expandedRowKey: ExpandedRowKey | null = null;
|
||||
|
||||
function render(): void {
|
||||
options.container.innerHTML = '';
|
||||
let lastGroup = '';
|
||||
const learningActionId = options.getLearningActionId();
|
||||
const dpadLearningActionId = options.getDpadLearningActionId();
|
||||
|
||||
// Auto-expand when learning starts
|
||||
if (learningActionId) {
|
||||
expandedRowKey = learningActionId;
|
||||
} else if (dpadLearningActionId) {
|
||||
expandedRowKey = `${dpadLearningActionId}:dpad`;
|
||||
}
|
||||
|
||||
for (const definition of CONTROLLER_BINDING_DEFINITIONS) {
|
||||
if (definition.group !== lastGroup) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'controller-config-group';
|
||||
header.textContent = definition.group;
|
||||
options.container.appendChild(header);
|
||||
lastGroup = definition.group;
|
||||
}
|
||||
|
||||
const binding = options.getBindings()[definition.id];
|
||||
|
||||
if (definition.bindingType === 'axis') {
|
||||
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
|
||||
renderAxisDpadRow(definition, binding as ResolvedControllerAxisBinding, dpadLearningActionId);
|
||||
} else {
|
||||
renderDiscreteRow(definition, binding, learningActionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiscreteRow(
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
|
||||
learningActionId: ControllerBindingActionId | null,
|
||||
): void {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = learningActionId === definition.id;
|
||||
|
||||
const row = createRow(definition.label, formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
render();
|
||||
});
|
||||
options.container.appendChild(row);
|
||||
|
||||
if (isExpanded) {
|
||||
const hint = isLearning
|
||||
? 'Press a button, trigger, or move a stick\u2026'
|
||||
: `Currently: ${formatControllerBindingSummary(binding)}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, definition.bindingType); },
|
||||
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
|
||||
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
|
||||
});
|
||||
options.container.appendChild(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAxisStickRow(
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
learningActionId: ControllerBindingActionId | null,
|
||||
): void {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = learningActionId === definition.id;
|
||||
|
||||
const row = createRow(`${definition.label} (Stick)`, formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
render();
|
||||
});
|
||||
options.container.appendChild(row);
|
||||
|
||||
if (isExpanded) {
|
||||
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
|
||||
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, 'axis'); },
|
||||
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
|
||||
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
|
||||
});
|
||||
options.container.appendChild(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAxisDpadRow(
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
dpadLearningActionId: ControllerBindingActionId | null,
|
||||
): void {
|
||||
const rowKey = `${definition.id as string}:dpad`;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = dpadLearningActionId === definition.id;
|
||||
|
||||
const dpadFallback: ControllerDpadFallback = binding.kind === 'none' ? 'none' : binding.dpadFallback;
|
||||
const badgeText = DPAD_FALLBACK_LABELS[dpadFallback];
|
||||
const row = createRow(`${definition.label} (D-pad)`, badgeText, dpadFallback === 'none', isExpanded);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
render();
|
||||
});
|
||||
options.container.appendChild(row);
|
||||
|
||||
if (isExpanded) {
|
||||
const hint = isLearning
|
||||
? 'Press a D-pad direction\u2026'
|
||||
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
onLearn: (e) => { e.stopPropagation(); options.onDpadLearn(definition.id); },
|
||||
onClear: (e) => { e.stopPropagation(); options.onDpadClear(definition.id); },
|
||||
onReset: (e) => { e.stopPropagation(); options.onDpadReset(definition.id); },
|
||||
});
|
||||
options.container.appendChild(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function createRow(labelText: string, badgeText: string, isDisabled: boolean, isExpanded: boolean): HTMLDivElement {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'controller-config-row';
|
||||
if (isExpanded) row.classList.add('expanded');
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'controller-config-label';
|
||||
label.textContent = labelText;
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'controller-config-right';
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'controller-config-badge';
|
||||
if (isDisabled) badge.classList.add('disabled');
|
||||
badge.textContent = badgeText;
|
||||
|
||||
const editIcon = document.createElement('span');
|
||||
editIcon.className = 'controller-config-edit-icon';
|
||||
editIcon.textContent = '\u270E';
|
||||
|
||||
right.appendChild(badge);
|
||||
right.appendChild(editIcon);
|
||||
row.appendChild(label);
|
||||
row.appendChild(right);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function createEditPanel(
|
||||
hintText: string,
|
||||
isLearning: boolean,
|
||||
callbacks: {
|
||||
onLearn: (e: Event) => void;
|
||||
onClear: (e: Event) => void;
|
||||
onReset: (e: Event) => void;
|
||||
},
|
||||
): HTMLDivElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'controller-config-edit-panel';
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'controller-config-edit-inner';
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'controller-config-edit-hint';
|
||||
if (isLearning) hint.classList.add('learning');
|
||||
hint.textContent = hintText;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'controller-config-edit-actions';
|
||||
|
||||
const learnButton = document.createElement('button');
|
||||
learnButton.type = 'button';
|
||||
learnButton.className = isLearning ? 'btn-learn active' : 'btn-learn';
|
||||
learnButton.textContent = isLearning ? 'Listening\u2026' : 'Learn';
|
||||
learnButton.addEventListener('click', callbacks.onLearn);
|
||||
|
||||
const clearButton = document.createElement('button');
|
||||
clearButton.type = 'button';
|
||||
clearButton.className = 'btn-secondary';
|
||||
clearButton.textContent = 'Clear';
|
||||
clearButton.addEventListener('click', callbacks.onClear);
|
||||
|
||||
const resetButton = document.createElement('button');
|
||||
resetButton.type = 'button';
|
||||
resetButton.className = 'btn-secondary';
|
||||
resetButton.textContent = 'Reset';
|
||||
resetButton.addEventListener('click', callbacks.onReset);
|
||||
|
||||
actions.appendChild(learnButton);
|
||||
actions.appendChild(clearButton);
|
||||
actions.appendChild(resetButton);
|
||||
|
||||
inner.appendChild(hint);
|
||||
inner.appendChild(actions);
|
||||
panel.appendChild(inner);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
return { render };
|
||||
}
|
||||
@@ -62,19 +62,19 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
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',
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,7 +106,10 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
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.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/);
|
||||
@@ -172,19 +175,19 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
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',
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -224,8 +227,14 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
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.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 });
|
||||
|
||||
@@ -18,21 +18,19 @@ function formatButtons(
|
||||
}
|
||||
|
||||
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,
|
||||
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.';
|
||||
@@ -97,7 +95,9 @@ export function createControllerDebugModal(
|
||||
);
|
||||
setStatus(
|
||||
activeDevice?.id ??
|
||||
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
|
||||
(ctx.state.connectedGamepads.length > 0
|
||||
? 'Controller connected.'
|
||||
: 'No controller detected.'),
|
||||
);
|
||||
ctx.dom.controllerDebugSummary.textContent =
|
||||
ctx.state.connectedGamepads.length > 0
|
||||
|
||||
@@ -27,121 +27,185 @@ function createClassList(initialTokens: string[] = []) {
|
||||
};
|
||||
}
|
||||
|
||||
test('controller select modal saves the selected preferred controller', async () => {
|
||||
function createFakeElement() {
|
||||
const attributes = new Map<string, string>();
|
||||
const el = {
|
||||
className: '',
|
||||
textContent: '',
|
||||
_innerHTML: '',
|
||||
value: '',
|
||||
disabled: false,
|
||||
selected: false,
|
||||
type: '',
|
||||
children: [] as any[],
|
||||
listeners: new Map<string, Array<(e?: any) => void>>(),
|
||||
classList: createClassList(),
|
||||
appendChild(child: any) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
addEventListener(type: string, listener: (e?: any) => void) {
|
||||
const existing = this.listeners.get(type) ?? [];
|
||||
existing.push(listener);
|
||||
this.listeners.set(type, existing);
|
||||
},
|
||||
dispatch(type: string) {
|
||||
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(fakeEvent);
|
||||
}
|
||||
},
|
||||
setAttribute(name: string, value: string) {
|
||||
attributes.set(name, value);
|
||||
},
|
||||
getAttribute(name: string) {
|
||||
return attributes.get(name) ?? null;
|
||||
},
|
||||
querySelector(selector: string) {
|
||||
const match = selector.match(/^\[data-testid="(.+)"\]$/);
|
||||
if (!match) return null;
|
||||
const testId = match[1];
|
||||
for (const child of el.children) {
|
||||
if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) {
|
||||
return child;
|
||||
}
|
||||
if (typeof child.querySelector === 'function') {
|
||||
const nested = child.querySelector(selector);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
focus: () => {},
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() {
|
||||
return el._innerHTML;
|
||||
},
|
||||
set(v: string) {
|
||||
el._innerHTML = v;
|
||||
if (v === '') el.children.length = 0;
|
||||
},
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function installFakeDom() {
|
||||
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, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createFakeElement(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
restore: () => {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext() {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: true,
|
||||
preferredGamepadId: 'pad-1',
|
||||
preferredGamepadLabel: 'pad-1',
|
||||
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: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
};
|
||||
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 dom = {
|
||||
overlay: { classList: createClassList(), focus: () => {} },
|
||||
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||
controllerSelectClose: createFakeElement(),
|
||||
controllerSelectPicker: createFakeElement(),
|
||||
controllerSelectSummary: createFakeElement(),
|
||||
controllerConfigList: createFakeElement(),
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
controllerSelectSave: createFakeElement(),
|
||||
};
|
||||
|
||||
return { state, dom };
|
||||
}
|
||||
|
||||
test('controller select modal saves preferred controller from dropdown selection', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async (update: {
|
||||
preferredGamepadId: string;
|
||||
preferredGamepadLabel: string;
|
||||
}) => {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
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, {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
state.controllerDeviceSelectedIndex = 1;
|
||||
|
||||
await modal.handleControllerSelectKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved, [
|
||||
{
|
||||
@@ -150,578 +214,114 @@ test('controller select modal saves the selected preferred controller', async ()
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
test('controller select modal learn mode captures fresh button input and persists binding', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerPreference: async () => {},
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
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, {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 0);
|
||||
|
||||
modal.handleControllerSelectKeydown({
|
||||
key: 'ArrowDown',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
// In the new compact list layout, children are:
|
||||
// [0] group header, [1] first binding row, [2] second binding row, ...
|
||||
// Click the row to expand the inline edit panel
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
firstRow.dispatch('click');
|
||||
|
||||
// After expanding, the edit panel is inserted after the row:
|
||||
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
|
||||
const editPanel = dom.controllerConfigList.children[2];
|
||||
// editPanel > inner > actions > learnButton
|
||||
const inner = editPanel.children[0];
|
||||
const actions = inner.children[1];
|
||||
const learnButton = actions.children[0];
|
||||
learnButton.dispatch('click');
|
||||
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [
|
||||
{ id: 'same-pad', index: 0, mapping: 'standard', connected: true },
|
||||
{ id: 'same-pad', index: 1, mapping: 'standard', connected: true },
|
||||
];
|
||||
state.activeGamepadId = 'same-pad';
|
||||
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const [firstOption, secondOption] = dom.controllerSelectPicker.children;
|
||||
assert.notEqual(firstOption.value, secondOption.value);
|
||||
|
||||
dom.controllerSelectPicker.value = secondOption.value;
|
||||
dom.controllerSelectPicker.dispatch('change');
|
||||
|
||||
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 });
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import {
|
||||
createControllerConfigForm,
|
||||
getControllerBindingDefinition,
|
||||
getDefaultControllerBinding,
|
||||
getDefaultDpadFallback,
|
||||
} from './controller-config-form.js';
|
||||
|
||||
function clampSelectedIndex(ctx: RendererContext): void {
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
@@ -19,10 +26,104 @@ export function createControllerSelectModal(
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let selectedControllerId: string | null = null;
|
||||
let selectedControllerKey: string | null = null;
|
||||
let lastRenderedDevicesKey = '';
|
||||
let lastRenderedActiveGamepadId: string | null = null;
|
||||
let lastRenderedPreferredId = '';
|
||||
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
|
||||
type ControllerBindingValue =
|
||||
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
|
||||
let learningActionId: ControllerBindingKey | null = null;
|
||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
||||
|
||||
const controllerConfigForm = createControllerConfigForm({
|
||||
container: ctx.dom.controllerConfigList,
|
||||
getBindings: () =>
|
||||
ctx.state.controllerConfig?.bindings ?? {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
getLearningActionId: () => learningActionId,
|
||||
getDpadLearningActionId: () => dpadLearningActionId,
|
||||
onLearn: (actionId, bindingType) => {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
dpadLearningActionId = null;
|
||||
const config = ctx.state.controllerConfig;
|
||||
bindingCapture = createControllerBindingCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
const currentBinding = config?.bindings[actionId];
|
||||
const currentDpadFallback =
|
||||
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
||||
? currentBinding.dpadFallback
|
||||
: 'none';
|
||||
bindingCapture.arm(
|
||||
bindingType === 'axis'
|
||||
? {
|
||||
actionId,
|
||||
bindingType: 'axis',
|
||||
dpadFallback: currentDpadFallback,
|
||||
}
|
||||
: {
|
||||
actionId,
|
||||
bindingType: 'discrete',
|
||||
},
|
||||
{
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
},
|
||||
);
|
||||
learningActionId = actionId;
|
||||
controllerConfigForm.render();
|
||||
setStatus(`Waiting for input for ${definition.label}.`);
|
||||
},
|
||||
onClear: (actionId) => {
|
||||
void saveBinding(actionId, { kind: 'none' });
|
||||
},
|
||||
onReset: (actionId) => {
|
||||
void saveBinding(actionId, getDefaultControllerBinding(actionId));
|
||||
},
|
||||
onDpadLearn: (actionId) => {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
learningActionId = null;
|
||||
const config = ctx.state.controllerConfig;
|
||||
bindingCapture = createControllerBindingCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
bindingCapture.arm(
|
||||
{ actionId, bindingType: 'dpad' },
|
||||
{
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
},
|
||||
);
|
||||
dpadLearningActionId = actionId;
|
||||
controllerConfigForm.render();
|
||||
setStatus(`Press a D-pad direction for ${definition.label}.`);
|
||||
},
|
||||
onDpadClear: (actionId) => {
|
||||
void saveDpadFallback(actionId, 'none');
|
||||
},
|
||||
onDpadReset: (actionId) => {
|
||||
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
|
||||
},
|
||||
});
|
||||
|
||||
function getDevicesKey(): string {
|
||||
return ctx.state.connectedGamepads
|
||||
@@ -30,9 +131,13 @@ export function createControllerSelectModal(
|
||||
.join('||');
|
||||
}
|
||||
|
||||
function getDeviceSelectionKey(device: { id: string; index: number }): string {
|
||||
return `${device.id}:${device.index}`;
|
||||
}
|
||||
|
||||
function syncSelectedControllerId(): void {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
selectedControllerId = selected?.id ?? null;
|
||||
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
|
||||
}
|
||||
|
||||
function syncSelectedIndexToCurrentController(): void {
|
||||
@@ -45,7 +150,9 @@ export function createControllerSelectModal(
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
|
||||
const preferredIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === preferredId,
|
||||
);
|
||||
if (preferredIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
||||
syncSelectedControllerId();
|
||||
@@ -60,90 +167,93 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
ctx.dom.controllerSelectList.innerHTML = '';
|
||||
function renderPicker(): void {
|
||||
ctx.dom.controllerSelectPicker.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',
|
||||
const option = document.createElement('option');
|
||||
option.value = getDeviceSelectionKey(device);
|
||||
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
|
||||
option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
|
||||
`#${device.index}`,
|
||||
device.mapping || 'unknown',
|
||||
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);
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')})`;
|
||||
ctx.dom.controllerSelectPicker.appendChild(option);
|
||||
});
|
||||
|
||||
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
|
||||
ctx.dom.controllerSelectSummary.textContent =
|
||||
ctx.state.connectedGamepads.length === 0
|
||||
? 'No controller detected.'
|
||||
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
|
||||
|
||||
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();
|
||||
async function saveControllerConfig(update: Parameters<typeof window.electronAPI.saveControllerConfig>[0]) {
|
||||
await window.electronAPI.saveControllerConfig(update);
|
||||
if (!ctx.state.controllerConfig) return;
|
||||
if (update.preferredGamepadId !== undefined) {
|
||||
ctx.state.controllerConfig.preferredGamepadId = update.preferredGamepadId;
|
||||
}
|
||||
if (update.preferredGamepadLabel !== undefined) {
|
||||
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
|
||||
}
|
||||
if (update.bindings) {
|
||||
ctx.state.controllerConfig.bindings = {
|
||||
...ctx.state.controllerConfig.bindings,
|
||||
...update.bindings,
|
||||
} as typeof ctx.state.controllerConfig.bindings;
|
||||
}
|
||||
}
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const shouldRender =
|
||||
getDevicesKey() !== lastRenderedDevicesKey ||
|
||||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
||||
preferredId !== lastRenderedPreferredId;
|
||||
if (shouldRender) {
|
||||
renderList();
|
||||
async function saveBinding(
|
||||
actionId: ControllerBindingKey,
|
||||
binding: ControllerBindingValue,
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
try {
|
||||
await saveControllerConfig({
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
},
|
||||
});
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
controllerConfigForm.render();
|
||||
setStatus(`${definition?.label ?? actionId} updated.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save binding: ${message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
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 saveDpadFallback(
|
||||
actionId: ControllerBindingKey,
|
||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
|
||||
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
||||
const updated = { ...currentBinding, dpadFallback };
|
||||
try {
|
||||
await saveControllerConfig({ bindings: { [actionId]: updated } });
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
controllerConfigForm.render();
|
||||
setStatus(`${definition?.label ?? actionId} D-pad updated.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save D-pad binding: ${message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +265,7 @@ export function createControllerSelectModal(
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.saveControllerPreference({
|
||||
await saveControllerConfig({
|
||||
preferredGamepadId: selected.id,
|
||||
preferredGamepadLabel: selected.id,
|
||||
});
|
||||
@@ -165,15 +275,55 @@ export function createControllerSelectModal(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.controllerConfig) {
|
||||
ctx.state.controllerConfig.preferredGamepadId = selected.id;
|
||||
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
|
||||
}
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
renderPicker();
|
||||
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
|
||||
}
|
||||
|
||||
function updateDevices(): void {
|
||||
if (!ctx.state.controllerSelectModalOpen) return;
|
||||
if (selectedControllerKey) {
|
||||
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => getDeviceSelectionKey(device) === selectedControllerKey,
|
||||
);
|
||||
if (preservedIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
||||
} else {
|
||||
syncSelectedIndexToCurrentController();
|
||||
}
|
||||
} else {
|
||||
syncSelectedIndexToCurrentController();
|
||||
}
|
||||
|
||||
if (bindingCapture && (learningActionId || dpadLearningActionId)) {
|
||||
const result = bindingCapture.poll({
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
});
|
||||
if (result) {
|
||||
if (result.bindingType === 'dpad') {
|
||||
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
|
||||
} else {
|
||||
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const shouldRender =
|
||||
getDevicesKey() !== lastRenderedDevicesKey ||
|
||||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
||||
preferredId !== lastRenderedPreferredId;
|
||||
if (shouldRender) {
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
|
||||
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
|
||||
setStatus('No controllers detected.');
|
||||
}
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): void {
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
syncSelectedIndexToCurrentController();
|
||||
@@ -183,16 +333,20 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
|
||||
window.focus();
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
renderList();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus('No controllers detected.');
|
||||
} else {
|
||||
setStatus('Select a controller to save as preferred.');
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
}
|
||||
|
||||
function closeControllerSelectModal(): void {
|
||||
if (!ctx.state.controllerSelectModalOpen) return;
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||
@@ -206,6 +360,14 @@ export function createControllerSelectModal(
|
||||
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (learningActionId || dpadLearningActionId) {
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
controllerConfigForm.render();
|
||||
setStatus('Controller learn mode cancelled.');
|
||||
return true;
|
||||
}
|
||||
closeControllerSelectModal();
|
||||
return true;
|
||||
}
|
||||
@@ -218,7 +380,7 @@ export function createControllerSelectModal(
|
||||
ctx.state.controllerDeviceSelectedIndex + 1,
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
renderPicker();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -231,12 +393,12 @@ export function createControllerSelectModal(
|
||||
ctx.state.controllerDeviceSelectedIndex - 1,
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderList();
|
||||
renderPicker();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
|
||||
event.preventDefault();
|
||||
void saveSelectedController();
|
||||
return true;
|
||||
@@ -252,6 +414,17 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
||||
void saveSelectedController();
|
||||
});
|
||||
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
||||
const selectedKey = ctx.dom.controllerSelectPicker.value;
|
||||
const selectedIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => getDeviceSelectionKey(device) === selectedKey,
|
||||
);
|
||||
if (selectedIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -280,19 +280,19 @@ function startControllerPolling(): void {
|
||||
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',
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
},
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
|
||||
@@ -63,8 +63,7 @@ body {
|
||||
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));
|
||||
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;
|
||||
@@ -1106,6 +1105,197 @@ iframe[id^='yomitan-popup'] {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.controller-select-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.controller-select-field select {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 14, 20, 0.9);
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.controller-select-summary {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.controller-config-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
|
||||
.controller-config-group {
|
||||
margin-top: 14px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(120, 190, 255, 0.9);
|
||||
}
|
||||
|
||||
.controller-config-group:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.controller-config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.controller-config-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.controller-config-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.controller-config-row.expanded {
|
||||
background: rgba(100, 180, 255, 0.06);
|
||||
border-color: rgba(100, 180, 255, 0.15);
|
||||
}
|
||||
|
||||
.controller-config-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.controller-config-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.controller-config-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
background: rgba(100, 180, 255, 0.12);
|
||||
color: rgba(100, 180, 255, 0.95);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controller-config-badge.disabled {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.controller-config-edit-icon {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.controller-config-row:hover .controller-config-edit-icon {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.controller-config-edit-panel {
|
||||
overflow: hidden;
|
||||
animation: configEditSlideIn 180ms ease-out;
|
||||
border-bottom: 1px solid rgba(100, 180, 255, 0.12);
|
||||
background: rgba(100, 180, 255, 0.04);
|
||||
}
|
||||
|
||||
@keyframes configEditSlideIn {
|
||||
from { max-height: 0; opacity: 0; }
|
||||
to { max-height: 120px; opacity: 1; }
|
||||
}
|
||||
|
||||
.controller-config-edit-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.controller-config-edit-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.controller-config-edit-hint.learning {
|
||||
color: rgba(100, 180, 255, 0.95);
|
||||
animation: configLearnPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes configLearnPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.controller-config-edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-learn {
|
||||
padding: 5px 14px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(100, 180, 255, 0.4);
|
||||
background: rgba(100, 180, 255, 0.15);
|
||||
color: rgba(100, 180, 255, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.btn-learn:hover {
|
||||
background: rgba(100, 180, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-learn.active {
|
||||
border-color: rgba(100, 180, 255, 0.7);
|
||||
background: rgba(100, 180, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.controller-debug-content {
|
||||
position: relative;
|
||||
width: min(760px, 94%);
|
||||
|
||||
@@ -59,9 +59,10 @@ export type RendererDom = {
|
||||
|
||||
controllerSelectModal: HTMLDivElement;
|
||||
controllerSelectClose: HTMLButtonElement;
|
||||
controllerSelectHint: HTMLDivElement;
|
||||
controllerSelectPicker: HTMLSelectElement;
|
||||
controllerSelectSummary: HTMLDivElement;
|
||||
controllerSelectStatus: HTMLDivElement;
|
||||
controllerSelectList: HTMLUListElement;
|
||||
controllerConfigList: HTMLDivElement;
|
||||
controllerSelectSave: HTMLButtonElement;
|
||||
|
||||
controllerDebugModal: HTMLDivElement;
|
||||
@@ -153,9 +154,10 @@ export function resolveRendererDom(): RendererDom {
|
||||
|
||||
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
||||
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
||||
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'),
|
||||
controllerSelectPicker: getRequiredElement<HTMLSelectElement>('controllerSelectPicker'),
|
||||
controllerSelectSummary: getRequiredElement<HTMLDivElement>('controllerSelectSummary'),
|
||||
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
||||
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'),
|
||||
controllerConfigList: getRequiredElement<HTMLDivElement>('controllerConfigList'),
|
||||
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
|
||||
|
||||
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
|
||||
@@ -166,7 +168,9 @@ export function resolveRendererDom(): RendererDom {
|
||||
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
|
||||
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
|
||||
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
||||
'controllerDebugButtonIndices',
|
||||
),
|
||||
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
|
||||
@@ -19,6 +19,7 @@ export const IPC_CHANNELS = {
|
||||
toggleDevTools: 'toggle-dev-tools',
|
||||
toggleOverlay: 'toggle-overlay',
|
||||
saveSubtitlePosition: 'save-subtitle-position',
|
||||
saveControllerConfig: 'save-controller-config',
|
||||
saveControllerPreference: 'save-controller-preference',
|
||||
setMecabEnabled: 'set-mecab-enabled',
|
||||
mpvCommand: 'mpv-command',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
JimakuDownloadQuery,
|
||||
JimakuFilesQuery,
|
||||
@@ -59,6 +60,99 @@ export function parseControllerPreferenceUpdate(value: unknown): ControllerPrefe
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBinding(value: unknown) {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
if (!isInteger(value.buttonIndex) || value.buttonIndex < 0) return null;
|
||||
return { kind: 'button', buttonIndex: value.buttonIndex };
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
|
||||
if (value.direction !== 'negative' && value.direction !== 'positive') return null;
|
||||
return { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBinding(value: unknown) {
|
||||
if (isObject(value) && value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
||||
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
|
||||
if (
|
||||
value.dpadFallback !== undefined &&
|
||||
value.dpadFallback !== 'none' &&
|
||||
value.dpadFallback !== 'horizontal' &&
|
||||
value.dpadFallback !== 'vertical'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: value.axisIndex,
|
||||
...(value.dpadFallback === undefined ? {} : { dpadFallback: value.dpadFallback }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
||||
if (!isObject(value)) return null;
|
||||
const update: ControllerConfigUpdate = {};
|
||||
|
||||
if (value.enabled !== undefined) {
|
||||
if (typeof value.enabled !== 'boolean') return null;
|
||||
update.enabled = value.enabled;
|
||||
}
|
||||
if (value.preferredGamepadId !== undefined) {
|
||||
if (typeof value.preferredGamepadId !== 'string') return null;
|
||||
update.preferredGamepadId = value.preferredGamepadId;
|
||||
}
|
||||
if (value.preferredGamepadLabel !== undefined) {
|
||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
||||
}
|
||||
|
||||
if (value.bindings !== undefined) {
|
||||
if (!isObject(value.bindings)) return null;
|
||||
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
|
||||
const discreteKeys = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
for (const key of discreteKeys) {
|
||||
if (value.bindings[key] === undefined) continue;
|
||||
const parsed = parseDiscreteBinding(value.bindings[key]);
|
||||
if (!parsed) return null;
|
||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||
}
|
||||
const axisKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
for (const key of axisKeys) {
|
||||
if (value.bindings[key] === undefined) continue;
|
||||
const parsed = parseAxisBinding(value.bindings[key]);
|
||||
if (!parsed) return null;
|
||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||
}
|
||||
update.bindings = bindings;
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||
if (!isObject(value)) return null;
|
||||
const { engine, sourceTrackId } = value;
|
||||
|
||||
@@ -223,7 +223,10 @@ export function ensureDefaultConfigBootstrap(options: {
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
||||
|
||||
if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
|
||||
if (
|
||||
existsSync(options.configFilePaths.jsoncPath) ||
|
||||
existsSync(options.configFilePaths.jsonPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
94
src/types.ts
94
src/types.ts
@@ -391,21 +391,84 @@ export type ControllerButtonBinding =
|
||||
|
||||
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
|
||||
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
|
||||
export type ControllerAxisDirection = 'negative' | 'positive';
|
||||
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
|
||||
|
||||
export interface ControllerNoneBinding {
|
||||
kind: 'none';
|
||||
}
|
||||
|
||||
export interface ControllerButtonInputBinding {
|
||||
kind: 'button';
|
||||
buttonIndex: number;
|
||||
}
|
||||
|
||||
export interface ControllerAxisDirectionInputBinding {
|
||||
kind: 'axis';
|
||||
axisIndex: number;
|
||||
direction: ControllerAxisDirection;
|
||||
}
|
||||
|
||||
export interface ControllerAxisInputBinding {
|
||||
kind: 'axis';
|
||||
axisIndex: number;
|
||||
dpadFallback?: ControllerDpadFallback;
|
||||
}
|
||||
|
||||
export type ControllerDiscreteBindingConfig =
|
||||
| ControllerButtonBinding
|
||||
| ControllerNoneBinding
|
||||
| ControllerButtonInputBinding
|
||||
| ControllerAxisDirectionInputBinding;
|
||||
|
||||
export type ResolvedControllerDiscreteBinding =
|
||||
| ControllerNoneBinding
|
||||
| ControllerButtonInputBinding
|
||||
| ControllerAxisDirectionInputBinding;
|
||||
|
||||
export type ControllerAxisBindingConfig =
|
||||
| ControllerAxisBinding
|
||||
| ControllerNoneBinding
|
||||
| ControllerAxisInputBinding;
|
||||
|
||||
export type ResolvedControllerAxisBinding =
|
||||
| ControllerNoneBinding
|
||||
| {
|
||||
kind: 'axis';
|
||||
axisIndex: number;
|
||||
dpadFallback: ControllerDpadFallback;
|
||||
};
|
||||
|
||||
export interface ControllerBindingsConfig {
|
||||
toggleLookup?: ControllerButtonBinding;
|
||||
closeLookup?: ControllerButtonBinding;
|
||||
toggleKeyboardOnlyMode?: ControllerButtonBinding;
|
||||
mineCard?: ControllerButtonBinding;
|
||||
quitMpv?: ControllerButtonBinding;
|
||||
previousAudio?: ControllerButtonBinding;
|
||||
nextAudio?: ControllerButtonBinding;
|
||||
playCurrentAudio?: ControllerButtonBinding;
|
||||
toggleMpvPause?: ControllerButtonBinding;
|
||||
leftStickHorizontal?: ControllerAxisBinding;
|
||||
leftStickVertical?: ControllerAxisBinding;
|
||||
rightStickHorizontal?: ControllerAxisBinding;
|
||||
rightStickVertical?: ControllerAxisBinding;
|
||||
toggleLookup?: ControllerDiscreteBindingConfig;
|
||||
closeLookup?: ControllerDiscreteBindingConfig;
|
||||
toggleKeyboardOnlyMode?: ControllerDiscreteBindingConfig;
|
||||
mineCard?: ControllerDiscreteBindingConfig;
|
||||
quitMpv?: ControllerDiscreteBindingConfig;
|
||||
previousAudio?: ControllerDiscreteBindingConfig;
|
||||
nextAudio?: ControllerDiscreteBindingConfig;
|
||||
playCurrentAudio?: ControllerDiscreteBindingConfig;
|
||||
toggleMpvPause?: ControllerDiscreteBindingConfig;
|
||||
leftStickHorizontal?: ControllerAxisBindingConfig;
|
||||
leftStickVertical?: ControllerAxisBindingConfig;
|
||||
rightStickHorizontal?: ControllerAxisBindingConfig;
|
||||
rightStickVertical?: ControllerAxisBindingConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedControllerBindingsConfig {
|
||||
toggleLookup?: ResolvedControllerDiscreteBinding;
|
||||
closeLookup?: ResolvedControllerDiscreteBinding;
|
||||
toggleKeyboardOnlyMode?: ResolvedControllerDiscreteBinding;
|
||||
mineCard?: ResolvedControllerDiscreteBinding;
|
||||
quitMpv?: ResolvedControllerDiscreteBinding;
|
||||
previousAudio?: ResolvedControllerDiscreteBinding;
|
||||
nextAudio?: ResolvedControllerDiscreteBinding;
|
||||
playCurrentAudio?: ResolvedControllerDiscreteBinding;
|
||||
toggleMpvPause?: ResolvedControllerDiscreteBinding;
|
||||
leftStickHorizontal?: ResolvedControllerAxisBinding;
|
||||
leftStickVertical?: ResolvedControllerAxisBinding;
|
||||
rightStickHorizontal?: ResolvedControllerAxisBinding;
|
||||
rightStickVertical?: ResolvedControllerAxisBinding;
|
||||
}
|
||||
|
||||
export interface ControllerButtonIndicesConfig {
|
||||
@@ -443,6 +506,8 @@ export interface ControllerPreferenceUpdate {
|
||||
preferredGamepadLabel: string;
|
||||
}
|
||||
|
||||
export type ControllerConfigUpdate = ControllerConfig;
|
||||
|
||||
export interface ControllerDeviceInfo {
|
||||
id: string;
|
||||
index: number;
|
||||
@@ -621,7 +686,7 @@ export interface ResolvedConfig {
|
||||
repeatDelayMs: number;
|
||||
repeatIntervalMs: number;
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ControllerBindingsConfig>;
|
||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||
};
|
||||
ankiConnect: AnkiConnectConfig & {
|
||||
enabled: boolean;
|
||||
@@ -977,6 +1042,7 @@ export interface ElectronAPI {
|
||||
getKeybindings: () => Promise<Keybinding[]>;
|
||||
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
||||
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
|
||||
Reference in New Issue
Block a user