feat(controller): add hold + press combos to main bindings (#328)

* feat(controller): add hold + press combos to main bindings

Main Alt+C actions can now be learned as combos such as L1 + D-pad right.
A combo fires on each press while its hold button is down, and claims its
press input so token move and popup scroll ignore it until it is released.
A plain action on a hold button fires on release, unless another bound
button was pressed during the hold.

The save path needed two changes for combos to persist. The IPC validator
rebuilt bindings field by field and dropped `hold`, so Alt+C showed a combo
from its local copy until the next reload turned it back into a plain
button. Validator and config resolver now share one parser. Saves also
replace the controller section, since the deep merge in patchRawConfig kept
the old `hold` when a combo was rebound to one button.

Also fixes Learn on the (D-pad) rows, which threw after cancel() cleared
the capture target.

* test(launcher): wait for content in background child test

The test polled for the fake app's output files to exist, but the shell
creates a redirect target before writing to it. A poll landing in that gap
read an empty env file and failed with '' !== '1', as seen once in CI.
Wait until both files have content instead.

* fix(controller): keep a diagonal push from learning as a combo

Learn took the first axis of a diagonal stick push as the hold and the
second as the press, saving a combo that one axis could never trigger. This
reached main Alt+C rows when they moved to combo capture. Axes crossing the
threshold in the same poll as the first input now count as one movement. A
second axis moved in a later poll still records a combo.

* fix(controller): only treat simultaneous axes as one movement

The diagonal push guard also blocked an axis activated in the same poll as a
button, so L1 plus a stick direction could not record the stick until it was
released and moved again. Apply the guard only when the first input is an
axis, so that input pair records as a combo again.
This commit is contained in:
sudacode authored and GitHub committed 2026-11-05 23:23:54 -08:00
1 parent 79e5535823
commit b31ddce627
30 files changed
+1078 -295

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed **Learn** on the **(D-pad)** rows in `Alt+C`, which threw an error instead of saving the D-pad direction.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- Controller actions in `Alt+C` can now be combos: click **Learn**, hold one button and press another, for example `L1` + D-pad right for **Next Audio**. Hold the first button and press the second repeatedly to run the action repeatedly. A hold button can keep its own action, which then runs when the button is released.
+17 -17
View File
@@ -128,62 +128,62 @@
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleCharacterNavigation": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"replaySubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"playNextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
+5 -1
View File
@@ -329,7 +329,11 @@ Gamepad input for the overlay, through the browser Gamepad API. Most actions onl
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller.
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller. Learn can also record a [combo](/usage#controller-support), saved on the binding as an optional `hold` input in the same form:
```jsonc
"nextAudio": { "kind": "button", "buttonIndex": 15, "hold": { "kind": "button", "buttonIndex": 4 } }
```
## Anki integration
+17 -17
View File
@@ -128,62 +128,62 @@
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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.
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
"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. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleCharacterNavigation": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"replaySubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"playNextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
+3 -1
View File
@@ -164,7 +164,9 @@ You can drive the overlay with a gamepad.
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want, or hold one button and press another to make a combo such as `L1` + D-pad right. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
A combo fires each time you press its second button while the first is down, so holding `L1` and pressing right twice runs it twice. While a combo's hold button is down, other buttons only run combos that use it, and its press no longer drives a **(D-pad)** or stick row. A hold button can keep its own action: that action then runs when you let go, unless you pressed another bound button during the hold. The exception is **Show Secondary Subtitle (Hold)**, which can't wait for release, so its row warns if it shares a hold button.
| Button | Action |
| --------------------- | -------------------------------------- |
+29
View File
@@ -331,6 +331,35 @@ testOutsideLinux(
},
);
test(
'controller combos survive the real save path and can be rebound to one button',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { overlay } = await useSession();
const L1 = { kind: 'button', buttonIndex: 4 };
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 };
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 };
const save = (bindings: Record<string, unknown>) =>
overlay.evaluate<Record<string, unknown>>(
`(async () => {
await window.electronAPI.saveControllerConfig(${JSON.stringify({ bindings })});
return (await window.electronAPI.getControllerConfig()).bindings;
})()`,
);
await save({ previousEntry: { ...DPAD_LEFT, hold: L1 } });
const bothCombos = await save({ nextEntry: { ...DPAD_RIGHT, hold: L1 } });
assert.deepEqual(bothCombos.previousEntry, { ...DPAD_LEFT, hold: L1 });
assert.deepEqual(bothCombos.nextEntry, { ...DPAD_RIGHT, hold: L1 });
const rebound = await save({ previousEntry: DPAD_LEFT, nextEntry: { kind: 'none' } });
assert.deepEqual(rebound.previousEntry, DPAD_LEFT);
assert.deepEqual(rebound.nextEntry, { kind: 'none' });
await save({ previousEntry: { kind: 'none' } });
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
+3 -1
View File
@@ -543,8 +543,10 @@ test('launchAppBackgroundDetached starts background child directly', async () =>
launchAppBackgroundDetached(appPath, 'info');
// The shell creates each file before writing to it, so wait for content, not existence.
const hasContent = (file: string) => fs.existsSync(file) && fs.statSync(file).size > 0;
const deadline = Date.now() + 1000;
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
while ((!hasContent(argsPath) || !hasContent(envPath)) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
+53
View File
@@ -1383,6 +1383,59 @@ test('parses descriptor-based controller bindings', () => {
});
});
test('parses a hold input on discrete controller bindings and rejects an invalid one', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"bindings": {
"nextAudio": {
"kind": "button",
"buttonIndex": 15,
"hold": { "kind": "button", "buttonIndex": 4 }
},
"nextEntry": {
"kind": "axis",
"axisIndex": 0,
"direction": "positive",
"hold": { "kind": "axis", "axisIndex": 2, "direction": "positive" }
},
"mineCard": {
"kind": "button",
"buttonIndex": 12,
"hold": { "kind": "button" }
}
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.controller.bindings.nextAudio, {
kind: 'button',
buttonIndex: 15,
hold: { kind: 'button', buttonIndex: 4 },
});
assert.deepEqual(config.controller.bindings.nextEntry, {
kind: 'axis',
axisIndex: 0,
direction: 'positive',
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
});
// A broken hold must not silently become a plain press of button 12.
assert.deepEqual(
config.controller.bindings.mineCard,
DEFAULT_CONFIG.controller.bindings.mineCard,
);
assert.ok(
service.getWarnings().some((warning) => warning.path === 'controller.bindings.mineCard'),
);
});
test('parses controller profiles as per-gamepad binding overrides', () => {
const dir = makeTempDir();
fs.writeFileSync(
+8 -1
View File
@@ -472,7 +472,7 @@ export function buildCoreConfigOptionRegistry(
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.`,
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.`,
},
{
path: `controller.bindings.${binding.id}.kind`,
@@ -505,6 +505,13 @@ export function buildCoreConfigOptionRegistry(
description:
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
},
{
path: `controller.bindings.${binding.id}.hold`,
kind: 'object' as const,
defaultValue: undefined,
description:
'Optional button or axis input, in the same form, that must be held down while pressing this one.',
},
]),
...axisBindings.flatMap((binding) => [
{
+3 -30
View File
@@ -1,6 +1,5 @@
import type {
ControllerAxisBinding,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
@@ -11,6 +10,7 @@ import type {
import {
isControllerFaceButtonLabels,
isControllerScreenAction,
parseControllerDiscreteBinding,
parseControllerReviewBinding,
type ControllerScreenBindings,
type ControllerScreenDefinition,
@@ -120,10 +120,6 @@ const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'pr
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
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';
}
@@ -152,29 +148,6 @@ function resolveLegacyAxisBinding(
};
}
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,
@@ -242,7 +215,7 @@ function applyControllerBindings(
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
const parsedObject = parseControllerDiscreteBinding(bindingValue);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
@@ -250,7 +223,7 @@ function applyControllerBindings(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis', and an optional button or axis 'hold'.",
);
}
}
+2 -5
View File
@@ -29,7 +29,7 @@ import {
dialog,
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { saveControllerConfigUpdate } from './main/controller-config-update.js';
import { createHachidoriExtensionRuntime } from './core/services/hachidori-extension';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
@@ -1569,10 +1569,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
});
},
saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
configService.patchRawConfig({
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
});
saveControllerConfigUpdate(configService, update);
// The overlay reads controller settings once at startup, and Alt+C runs in its own
// window, so remaps would otherwise only apply after a restart.
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.controllerConfigChanged);
+73 -1
View File
@@ -1,7 +1,79 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { applyControllerConfigUpdate } from './controller-config-update.js';
import { ConfigService } from '../config/service';
import { parseControllerConfigUpdate } from '../shared/ipc/validators';
import {
applyControllerConfigUpdate,
saveControllerConfigUpdate,
} from './controller-config-update.js';
const L1 = { kind: 'button', buttonIndex: 4 } as const;
const L2 = { kind: 'button', buttonIndex: 6 } as const;
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 } as const;
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 } as const;
/** One Alt+C save for a profile, through the same validator and merge as the real IPC handler. */
function createSaveHarness() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-controller-save-'));
const service = new ConfigService(dir);
return {
service,
save: (action: string, binding: unknown) => {
const update = parseControllerConfigUpdate({
profiles: { 'pad-1': { bindings: { [action]: binding } } },
});
assert.ok(update, 'the validator accepts the update');
saveControllerConfigUpdate(service, update);
},
savedBinding: (action: 'nextAudio' | 'previousAudio' | 'nextEntry') =>
service.getConfig().controller.profiles['pad-1']?.bindings[action],
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
};
}
test('combos sharing a hold button or a press button all survive later saves', () => {
const harness = createSaveHarness();
try {
harness.save('nextAudio', { ...DPAD_LEFT, hold: L1 });
harness.save('previousAudio', { ...DPAD_RIGHT, hold: L1 });
harness.save('nextEntry', { ...DPAD_LEFT, hold: L2 });
assert.deepEqual(harness.savedBinding('nextAudio'), { ...DPAD_LEFT, hold: L1 });
assert.deepEqual(harness.savedBinding('previousAudio'), { ...DPAD_RIGHT, hold: L1 });
assert.deepEqual(harness.savedBinding('nextEntry'), { ...DPAD_LEFT, hold: L2 });
} finally {
harness.cleanup();
}
});
test('rebinding a combo to one button drops its hold', () => {
const harness = createSaveHarness();
try {
harness.save('nextAudio', { ...DPAD_LEFT, hold: L1 });
harness.save('nextAudio', DPAD_RIGHT);
assert.deepEqual(harness.savedBinding('nextAudio'), DPAD_RIGHT);
assert.deepEqual(
harness.service.getRawConfig().controller?.profiles?.['pad-1']?.bindings?.nextAudio,
DPAD_RIGHT,
);
} finally {
harness.cleanup();
}
});
test('the validator rejects an invalid hold instead of saving a plain press', () => {
assert.equal(
parseControllerConfigUpdate({
bindings: { nextAudio: { ...DPAD_LEFT, hold: { kind: 'face' } } },
}),
null,
);
});
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
const next = applyControllerConfigUpdate(
+16
View File
@@ -1,3 +1,4 @@
import type { ConfigService } from '../config/service';
import type { ControllerConfigUpdate, RawConfig } from '../types';
type RawControllerConfig = NonNullable<RawConfig['controller']>;
@@ -96,6 +97,21 @@ function mergeControllerProfile(
return nextProfile;
}
/**
* Saves an Alt+C update to the config file. The controller section is replaced, not patched:
* `patchRawConfig` deep-merges, so a combo rebound to one button would keep its old `hold`.
*/
export function saveControllerConfigUpdate(
configService: Pick<ConfigService, 'getRawConfig' | 'saveRawConfig'>,
update: ControllerConfigUpdate,
): void {
const rawConfig = configService.getRawConfig();
configService.saveRawConfig({
...rawConfig,
controller: applyControllerConfigUpdate(rawConfig.controller, update),
});
}
export function applyControllerConfigUpdate(
currentController: RawConfig['controller'] | undefined,
update: ControllerConfigUpdate,
@@ -21,45 +21,27 @@ function createSnapshot(
};
}
test('controller binding capture waits for neutral-to-active button edge', () => {
test('controller binding capture waits for a neutral-to-active D-pad edge', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.2,
});
const heldButtons = createSnapshot({
buttons: [{ value: 1, pressed: true, touched: true }],
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
const heldDpad = createSnapshot({
buttons: buttons.map((button, index) =>
index === 15 ? { value: 1, pressed: true, touched: true } : button,
),
});
capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons);
capture.arm({ actionId: 'leftStickHorizontal', bindingType: 'dpad' }, heldDpad);
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' },
assert.equal(capture.poll(heldDpad), null);
assert.equal(capture.poll(createSnapshot({ buttons })), null);
assert.deepEqual(capture.poll(heldDpad), {
actionId: 'leftStickHorizontal',
bindingType: 'dpad',
dpadDirection: 'horizontal',
});
});
@@ -69,7 +51,10 @@ test('controller binding capture ignores analog drift inside deadzone', () => {
stickDeadzone: 0.3,
});
capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot());
capture.arm(
{ actionId: 'leftStickHorizontal', bindingType: 'axis', dpadFallback: 'none' },
createSnapshot(),
);
assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null);
assert.equal(capture.isArmed(), true);
@@ -1,8 +1,4 @@
import type {
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types';
import type { ControllerDpadFallback, ResolvedControllerAxisBinding } from '../../types';
type ControllerButtonState = {
value: number;
@@ -15,11 +11,8 @@ type ControllerBindingCaptureSnapshot = {
buttons: readonly ControllerButtonState[];
};
/** Learn for stick rows and their D-pad rows; discrete rows use the combo capture. */
type ControllerBindingCaptureTarget =
| {
actionId: string;
bindingType: 'discrete';
}
| {
actionId: string;
bindingType: 'axis';
@@ -31,11 +24,6 @@ type ControllerBindingCaptureTarget =
};
type ControllerBindingCaptureResult =
| {
actionId: string;
bindingType: 'discrete';
binding: ResolvedControllerDiscreteBinding;
}
| {
actionId: string;
bindingType: 'axis';
@@ -134,58 +122,24 @@ export function createControllerBindingCapture(options: {
const dpadDirection: ControllerDpadFallback =
index === 12 || index === 13 ? 'vertical' : 'horizontal';
const { actionId } = target;
cancel();
return {
actionId: target.actionId,
bindingType: 'dpad' as const,
dpadDirection,
};
return { actionId, bindingType: 'dpad', 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,
},
};
const result: ControllerBindingCaptureResult = {
actionId: target.actionId,
bindingType: 'axis',
binding: { kind: 'axis', axisIndex: index, dpadFallback: target.dpadFallback },
};
cancel();
return result;
}
@@ -73,3 +73,49 @@ test('combo capture never records a trigger axis resting position', () => {
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
});
});
test('combo capture records one axis from a diagonal push, not a hold and a press', () => {
const capture = createCapture();
capture.arm(snapshot());
const diagonal = snapshot([], [0.9, 0.9, 0, 0]);
const hold = { kind: 'axis', axisIndex: 0, direction: 'positive' } as const;
assert.deepEqual(capture.poll(diagonal), { status: 'holding', hold });
assert.deepEqual(capture.poll(diagonal), { status: 'holding', hold });
assert.deepEqual(capture.poll(snapshot()), {
status: 'done',
binding: { kind: 'combo', press: hold, hold: null },
});
});
test('combo capture still records a second axis pressed after the first', () => {
const capture = createCapture();
capture.arm(snapshot());
capture.poll(snapshot([], [0.9, 0, 0, 0]));
assert.deepEqual(capture.poll(snapshot([], [0.9, 0.9, 0, 0])), {
status: 'done',
binding: {
kind: 'combo',
press: { kind: 'axis', axisIndex: 1, direction: 'positive' },
hold: { kind: 'axis', axisIndex: 0, direction: 'positive' },
},
});
});
test('combo capture records a button and an axis activated in the same poll as a combo', () => {
const capture = createCapture();
capture.arm(snapshot());
const both = snapshot([4], [0.9, 0, 0, 0]);
const hold = { kind: 'button', buttonIndex: 4 } as const;
assert.deepEqual(capture.poll(both), { status: 'holding', hold });
assert.deepEqual(capture.poll(both), {
status: 'done',
binding: {
kind: 'combo',
press: { kind: 'axis', axisIndex: 0, direction: 'positive' },
hold,
},
});
});
@@ -1,4 +1,4 @@
import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types';
import type { ControllerRawInput } from '../../types';
type ControllerButtonState = {
value: number;
@@ -13,19 +13,23 @@ type ControllerSnapshot = {
export type ControllerComboCaptureState =
| { status: 'waiting' }
| { status: 'holding'; hold: ControllerReviewInput }
| { status: 'done'; binding: ResolvedControllerReviewBinding };
| { status: 'holding'; hold: ControllerRawInput }
| { status: 'done'; binding: ControllerCapturedCombo };
type RawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
export type ControllerCapturedCombo = {
kind: 'combo';
press: ControllerRawInput;
hold: ControllerRawInput | null;
};
function inputKey(input: RawInput): string {
function inputKey(input: ControllerRawInput): string {
return input.kind === 'button'
? `button:${input.buttonIndex}`
: `axis:${input.axisIndex}:${input.direction}`;
}
/**
* Learn for review bindings. Waits for a first input; letting go of it records it alone,
* Learn for discrete and dialog bindings. Waits for a first input; letting go of it records it alone,
* while pressing a second input during the hold records "hold first + press second".
* Buttons already down when learning starts are ignored until released. Axis directions
* active at the start stay ignored for the whole capture: some pads rest a trigger axis at
@@ -37,10 +41,10 @@ export function createControllerComboCapture(options: {
}) {
const blocked = new Set<string>();
const restingAxes = new Set<string>();
let first: RawInput | null = null;
let first: ControllerRawInput | null = null;
function activeInputs(snapshot: ControllerSnapshot): RawInput[] {
const inputs: RawInput[] = [];
function activeInputs(snapshot: ControllerSnapshot): ControllerRawInput[] {
const inputs: ControllerRawInput[] = [];
snapshot.buttons.forEach((button, buttonIndex) => {
if (button && (Boolean(button.pressed) || button.value >= options.triggerDeadzone)) {
inputs.push({ kind: 'button', buttonIndex });
@@ -77,6 +81,11 @@ export function createControllerComboCapture(options: {
const next = fresh[0];
if (!next) return { status: 'waiting' };
first = next;
// Axes crossing the threshold in the same poll are one movement, such as a diagonal push.
// Buttons sort first, so an axis here means every other fresh input is an axis too.
if (next.kind === 'axis') {
for (const input of fresh.slice(1)) blocked.add(inputKey(input));
}
return { status: 'holding', hold: first };
}
@@ -0,0 +1,129 @@
import { controllerReviewInputSourceKey as inputKey } from '../../shared/controller-review';
import type { ControllerRawInput, ResolvedControllerDiscreteBinding } from '../../types';
import type { GamepadLike } from './gamepad-controller.js';
type BoundBinding = Exclude<ResolvedControllerDiscreteBinding, { kind: 'none' }>;
const isBound = (binding: ResolvedControllerDiscreteBinding): binding is BoundBinding =>
binding.kind !== 'none';
/**
* Per-poll view of hold + press combos in the main controller bindings. Dialog layouts have
* their own matching in `controller-screen-layout.ts`; the rules for choosing a binding match.
*
* A press belongs to the binding that matches the hold buttons down when it starts, so
* pressing the same input again during one hold fires the combo again, and letting go of the
* hold first does not turn the press into its plain binding.
*/
export function createControllerComboTracker() {
let pressedLastPoll: ReadonlySet<string> = new Set();
let holdKeys: ReadonlySet<string> = new Set();
let heldHoldKeys: string[] = [];
let usedHolds: ReadonlySet<string> = new Set();
/** Holds used during their press, kept for the poll that sees them released. */
let cancelledTaps: ReadonlySet<string> = new Set();
const claimedInputs = new Map<string, ControllerRawInput>();
function update(
bindings: readonly ResolvedControllerDiscreteBinding[],
isPressed: (input: ControllerRawInput) => boolean,
): void {
const bound = bindings.filter(isBound);
const pressed = new Set<string>();
for (const binding of bound) {
for (const input of binding.hold ? [binding, binding.hold] : [binding]) {
if (isPressed(input)) pressed.add(inputKey(input));
}
}
holdKeys = new Set(bound.flatMap((binding) => (binding.hold ? [inputKey(binding.hold)] : [])));
heldHoldKeys = [...holdKeys].filter((key) => pressed.has(key));
const freshPresses = bound
.map(inputKey)
.filter((key) => pressed.has(key) && !pressedLastPoll.has(key));
const nextUsedHolds = new Set(
heldHoldKeys.filter(
(holdKey) => usedHolds.has(holdKey) || freshPresses.some((key) => key !== holdKey),
),
);
cancelledTaps = new Set([...usedHolds, ...nextUsedHolds]);
usedHolds = nextUsedHolds;
pressedLastPoll = pressed;
for (const key of claimedInputs.keys()) {
if (!pressed.has(key)) claimedInputs.delete(key);
}
for (const binding of bound) {
if (!binding.hold) continue;
const pressKey = inputKey(binding);
const holdKey = inputKey(binding.hold);
if (pressKey !== holdKey && pressed.has(pressKey) && pressed.has(holdKey)) {
claimedInputs.set(pressKey, binding);
}
}
}
return {
update,
reset(): void {
pressedLastPoll = new Set();
holdKeys = new Set();
heldHoldKeys = [];
usedHolds = new Set();
cancelledTaps = new Set();
claimedInputs.clear();
},
/**
* Whether a press of this binding counts right now: with one hold button down, only the
* binding that uses it; with none, only a plain binding. Two holds down match nothing.
*/
matches(binding: ResolvedControllerDiscreteBinding): boolean {
if (!isBound(binding)) return false;
const pressKey = inputKey(binding);
const others = heldHoldKeys.filter((key) => key !== pressKey);
if (others.length > 1) return false;
return others[0] === (binding.hold ? inputKey(binding.hold) : undefined);
},
/** A plain binding on a hold button waits for release, when a tap can be told from a hold. */
firesOnRelease(binding: ResolvedControllerDiscreteBinding): boolean {
return isBound(binding) && !binding.hold && holdKeys.has(inputKey(binding));
},
/** Another bound input was pressed during this hold, so its release is not a tap. */
isTapCancelled(binding: ResolvedControllerDiscreteBinding): boolean {
return isBound(binding) && cancelledTaps.has(inputKey(binding));
},
/**
* The pad with inputs claimed by a combo reading as released, for the stick actions and
* their D-pad fallbacks. A claim lasts until the input is released, so letting go of the
* hold first does not start moving the word selection.
*/
withoutClaimedInputs(gamepad: GamepadLike): GamepadLike {
if (claimedInputs.size === 0) return gamepad;
const axes = [...gamepad.axes];
const buttons = [...gamepad.buttons];
for (const input of claimedInputs.values()) {
if (input.kind === 'button') {
buttons[input.buttonIndex] = { value: 0, pressed: false, touched: false };
} else if ((axes[input.axisIndex] ?? 0) > 0 === (input.direction === 'positive')) {
axes[input.axisIndex] = 0;
}
}
// Native Gamepad fields are prototype getters, so object spread would drop them.
return {
id: gamepad.id,
index: gamepad.index,
connected: gamepad.connected,
mapping: gamepad.mapping,
timestamp: gamepad.timestamp,
axes,
buttons,
};
},
};
}
@@ -919,3 +919,261 @@ test('gamepad controller pauses mpv outside keyboard-only mode, but not under a
assert.deepEqual(harness.calls, ['toggleMpvPause']);
});
/** Harness driven frame by frame: `pads(4, 15)` polls once with those buttons down. */
function createComboHarness(
bindings: Partial<Bindings>,
deps: Partial<ControllerOptions> = {},
extraConfig: Partial<ResolvedControllerConfig> = {},
) {
let pressed: number[] = [];
let now = 0;
const harness = createHarness({
gamepads: () => [
createGamepad('pad-1', {
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
}),
],
config: createControllerConfig({ ...extraConfig, bindings }),
deps: { getLookupWindowOpen: () => true, ...deps },
});
return {
...harness,
pads: (...buttons: number[]) => {
pressed = buttons;
now += 16;
harness.poll(now);
},
};
}
const L1 = 4;
const R1 = 5;
const DPAD_UP = 12;
const DPAD_RIGHT = 15;
const L1_DPAD_RIGHT = {
kind: 'button',
buttonIndex: DPAD_RIGHT,
hold: { kind: 'button', buttonIndex: L1 },
} as const;
test('holding L1 and pressing D-pad right twice fires the combo twice and nothing else', () => {
// L1 keeps its default Play Current Audio, and the D-pad keeps moving between words.
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
harness.pads();
harness.pads(L1);
harness.pads(L1, DPAD_RIGHT);
harness.pads(L1);
harness.pads(L1, DPAD_RIGHT);
harness.pads(L1);
harness.pads();
assert.deepEqual(harness.calls, ['nextAudio', 'nextAudio']);
});
test('a plain button that is also a hold button fires when tapped, on release', () => {
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
harness.pads();
harness.pads(L1);
assert.deepEqual(harness.calls, []);
harness.pads();
assert.deepEqual(harness.calls, ['playCurrentAudio']);
// The next tap is unaffected by a combo used on an earlier hold.
harness.pads(L1);
harness.pads(L1, DPAD_RIGHT);
harness.pads();
harness.pads(L1);
harness.pads();
assert.deepEqual(harness.calls, ['playCurrentAudio', 'nextAudio', 'playCurrentAudio']);
});
test('a plain button stays on press while no combo holds it', () => {
const harness = createComboHarness({ nextAudio: { kind: 'button', buttonIndex: R1 } });
harness.pads();
harness.pads(L1);
assert.deepEqual(harness.calls, ['playCurrentAudio']);
});
test('a tap on a hold button also fires outside keyboard-only mode', () => {
const harness = createComboHarness(
{
previousSubtitle: { kind: 'button', buttonIndex: L1 },
nextSubtitle: { kind: 'button', buttonIndex: R1, hold: { kind: 'button', buttonIndex: L1 } },
},
{ getKeyboardModeEnabled: () => false },
);
harness.pads();
harness.pads(L1);
harness.pads();
assert.deepEqual(harness.calls, ['previousSubtitle']);
});
test('a tap on a hold button is dropped if its action stops applying before the release', () => {
let lookupOpen = true;
const harness = createComboHarness(
{ nextAudio: L1_DPAD_RIGHT },
{ getLookupWindowOpen: () => lookupOpen },
);
harness.pads();
harness.pads(L1);
lookupOpen = false;
harness.pads(L1);
lookupOpen = true;
harness.pads(L1);
harness.pads();
assert.deepEqual(harness.calls, []);
});
test('pressing a plain binding during a hold does nothing and cancels the tap', () => {
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
harness.pads();
harness.pads(L1);
harness.pads(L1, 2); // X is Mine Card; there is no L1 + X.
harness.pads();
assert.deepEqual(harness.calls, []);
});
test('letting go of the hold first does not fire the plain binding on the same input', () => {
const harness = createComboHarness({
playCurrentAudio: { kind: 'none' },
nextAudio: L1_DPAD_RIGHT,
nextEntry: { kind: 'button', buttonIndex: DPAD_RIGHT },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'none' },
});
harness.pads();
harness.pads(L1);
harness.pads(L1, DPAD_RIGHT);
harness.pads(DPAD_RIGHT);
harness.pads();
assert.deepEqual(harness.calls, ['nextAudio']);
harness.pads(DPAD_RIGHT);
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
});
test('a combo needs its hold down first, and two holds at once match nothing', () => {
const harness = createComboHarness({
playCurrentAudio: { kind: 'none' },
nextAudio: L1_DPAD_RIGHT,
previousAudio: {
kind: 'button',
buttonIndex: DPAD_RIGHT,
hold: { kind: 'button', buttonIndex: R1 },
},
});
harness.pads();
harness.pads(DPAD_RIGHT);
harness.pads(L1, DPAD_RIGHT);
harness.pads();
harness.pads(L1, R1);
harness.pads(L1, R1, DPAD_RIGHT);
// Only the first press of the D-pad moves a word; the combos never fire.
assert.deepEqual(harness.calls, ['moveSelection:1']);
});
test('a combo keeps the D-pad from driving its stick row until the D-pad is released', () => {
const harness = createComboHarness({
playCurrentAudio: { kind: 'none' },
nextAudio: L1_DPAD_RIGHT,
nextEntry: {
kind: 'button',
buttonIndex: DPAD_UP,
hold: { kind: 'button', buttonIndex: L1 },
},
});
harness.pads();
harness.pads(L1);
harness.pads(L1, DPAD_RIGHT);
harness.pads(L1, DPAD_UP);
harness.pads(DPAD_UP);
harness.pads();
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
// L1 with a direction no combo uses still moves between words.
harness.pads(L1);
harness.pads(L1, 14);
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry', 'moveSelection:-1']);
});
test('L1 and L2 can each hold the same press for different actions', () => {
const L2 = 6;
const DPAD_LEFT = 14;
const harness = createComboHarness({
// L2 keeps its default Quit MPV, so this also pins that a combo never quits on release.
playCurrentAudio: { kind: 'none' },
nextAudio: {
kind: 'button',
buttonIndex: DPAD_LEFT,
hold: { kind: 'button', buttonIndex: L1 },
},
nextEntry: {
kind: 'button',
buttonIndex: DPAD_LEFT,
hold: { kind: 'button', buttonIndex: L2 },
},
});
harness.pads();
harness.pads(L1);
harness.pads(L1, DPAD_LEFT);
harness.pads();
harness.pads(L2);
harness.pads(L2, DPAD_LEFT);
harness.pads();
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
});
test('the secondary subtitle hold can be a combo', () => {
const harness = createComboHarness({
playCurrentAudio: { kind: 'none' },
holdSecondarySub: {
kind: 'button',
buttonIndex: R1,
hold: { kind: 'button', buttonIndex: L1 },
},
nextAudio: { kind: 'none' },
});
harness.pads();
harness.pads(R1);
harness.pads();
harness.pads(L1);
harness.pads(L1, R1);
harness.pads(L1);
assert.deepEqual(harness.secondarySubHeld, [true, false]);
});
test('a dialog owning the pad keeps combos from firing and from leaving a tap behind', () => {
let suspended = false;
const harness = createComboHarness(
{ nextAudio: L1_DPAD_RIGHT },
{ getSuspended: () => suspended },
);
harness.pads();
harness.pads(L1);
suspended = true;
harness.pads(L1, DPAD_RIGHT);
suspended = false;
harness.pads(L1, DPAD_RIGHT);
harness.pads();
assert.deepEqual(harness.calls, []);
});
+64 -35
View File
@@ -6,6 +6,7 @@ import type {
ResolvedControllerDiscreteBinding,
} from '../../types';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
import { createControllerComboTracker } from './controller-combo-tracker.js';
type ControllerButtonState = {
value: number;
@@ -228,6 +229,7 @@ function syncHeldActionBlocked(
state.initialFired = true;
}
/** Whether the binding's press input is down. A combo's `hold` is checked by the combo tracker. */
export function resolveDiscreteBindingPressed(
gamepad: GamepadLike,
binding: ResolvedControllerDiscreteBinding,
@@ -295,8 +297,24 @@ const WATCHING_ACTIONS = [
'playNextSubtitle',
] as const;
/** Every action fired by a fresh press; `holdSecondarySub` follows the button instead. */
const PRESS_ACTIONS = [
'toggleKeyboardOnlyMode',
'toggleLookup',
'closeLookup',
'mineCard',
'quitMpv',
'toggleCharacterNavigation',
...POPUP_ONLY_ACTIONS,
...WATCHING_ACTIONS,
] as const;
export function createGamepadController(options: GamepadControllerOptions) {
let previousActions = new Map<string, boolean>();
const comboTracker = createControllerComboTracker();
/** Plain actions on a hold button, pressed and waiting to see whether they were a tap. */
const pendingTaps = new Set<string>();
const handledThisPoll = new Set<string>();
let selectionHold = createHoldState();
let jumpHold = createHoldState();
let activeGamepadId: string | null = null;
@@ -361,14 +379,38 @@ export function createGamepadController(options: GamepadControllerOptions) {
config: ResolvedControllerConfig,
action: () => void,
): void {
handledThisPoll.add(actionKey);
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
const wasPressed = previousActions.get(actionKey) ?? false;
previousActions.set(actionKey, isPressed);
if (!wasPressed && isPressed) {
if (isPressed === wasPressed) return;
if (!isPressed) {
if (pendingTaps.delete(actionKey) && !comboTracker.isTapCancelled(binding)) action();
return;
}
if (!comboTracker.matches(binding)) return;
if (comboTracker.firesOnRelease(binding)) {
pendingTaps.add(actionKey);
} else {
action();
}
}
// The action does not apply this poll. Its edge state keeps following the pad, so a release
// is not missed and a held button waits for a fresh press, and a pending tap is dropped.
function trackActionWithoutFiring(
actionKey: (typeof PRESS_ACTIONS)[number],
activeGamepad: GamepadLike,
config: ResolvedControllerConfig,
): void {
previousActions.set(
actionKey,
resolveDiscreteBindingPressed(activeGamepad, config.bindings[actionKey], config),
);
if (!handledThisPoll.has(actionKey)) pendingTaps.delete(actionKey);
}
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
@@ -410,28 +452,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
config: ResolvedControllerConfig,
now: number,
): void {
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],
['previousEntry', config.bindings.previousEntry],
['nextEntry', config.bindings.nextEntry],
['toggleCharacterNavigation', config.bindings.toggleCharacterNavigation],
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
] as const;
for (const [actionKey, binding] of discreteActions) {
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
for (const actionKey of PRESS_ACTIONS) {
trackActionWithoutFiring(actionKey, activeGamepad, config);
}
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
const selectionValue = resolveAxisBindingValue(
activeGamepad,
axisGamepad,
config.bindings.leftStickHorizontal,
config.triggerDeadzone,
activationThreshold,
@@ -442,7 +470,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
syncHeldActionBlocked(
jumpHold,
resolveAxisBindingValue(
activeGamepad,
axisGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
@@ -463,6 +491,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
const previousActiveGamepadId = activeGamepadId;
publishState(connectedGamepads, activeGamepad);
handledThisPoll.clear();
if (!activeGamepad) {
syncSecondarySubHold(false);
@@ -472,6 +501,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
// the press that reveals it started after the screen opened, so it should count.
screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null;
previousActions = new Map();
comboTracker.reset();
pendingTaps.clear();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
lastPollAt = null;
@@ -481,11 +512,17 @@ export function createGamepadController(options: GamepadControllerOptions) {
if (activeGamepad.id !== previousActiveGamepadId) {
activeScreenLayout = null;
previousActions = new Map();
comboTracker.reset();
pendingTaps.clear();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
}
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
comboTracker.update(
[...PRESS_ACTIONS, 'holdSecondarySub' as const].map((key) => activeConfig.bindings[key]),
(input) => resolveDiscreteBindingPressed(activeGamepad, input, activeConfig),
);
// A screen layout or another window owning the controller pauses every normal action,
// including the keyboard-mode toggle and the secondary subtitle hold. Their edge state
@@ -521,7 +558,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
activeGamepad,
activeConfig.bindings.holdSecondarySub,
activeConfig,
),
) &&
comboTracker.matches(activeConfig.bindings.holdSecondarySub),
);
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
@@ -592,6 +630,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
if (options.getLookupWindowOpen()) {
handleActionEdge(
@@ -631,7 +670,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
const primaryScroll = resolveAxisBindingValue(
activeGamepad,
axisGamepad,
activeConfig.bindings.leftStickVertical,
activeConfig.triggerDeadzone,
activeConfig.stickDeadzone,
@@ -644,7 +683,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
handleJumpAxis(
resolveAxisBindingValue(
activeGamepad,
axisGamepad,
activeConfig.bindings.rightStickVertical,
activeConfig.triggerDeadzone,
activationThreshold,
@@ -654,24 +693,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
} else {
resetHeldAction(jumpHold);
// Popup-only buttons keep tracking the pad while the popup is closed, like blocked
// actions do: a release is not missed, and a button held as the popup opens waits for
// a fresh press instead of firing at once.
for (const actionKey of POPUP_ONLY_ACTIONS) {
previousActions.set(
actionKey,
resolveDiscreteBindingPressed(
activeGamepad,
activeConfig.bindings[actionKey],
activeConfig,
),
);
trackActionWithoutFiring(actionKey, activeGamepad, activeConfig);
}
}
handleSelectionAxis(
resolveAxisBindingValue(
activeGamepad,
axisGamepad,
activeConfig.bindings.leftStickHorizontal,
activeConfig.triggerDeadzone,
activationThreshold,
@@ -39,6 +39,44 @@ test('a button on the D-pad clashes with a D-pad row that is still on', () => {
);
});
const L1_DPAD_RIGHT = {
kind: 'button',
buttonIndex: 15,
hold: { kind: 'button', buttonIndex: 4 },
} as const;
test('a combo does not clash with its hold or press used elsewhere on their own', () => {
// L1 is Play Current Audio by default; the D-pad is still on Token Move.
assert.deepEqual(issuesWith({ nextAudio: L1_DPAD_RIGHT }), {});
});
test('two actions on the same combo both warn', () => {
assert.deepEqual(issuesWith({ nextAudio: L1_DPAD_RIGHT, nextEntry: L1_DPAD_RIGHT }), {
nextAudio: 'Same as Next Entry',
nextEntry: 'Same as Next Audio',
});
});
test('a combo holding and pressing the same button warns', () => {
assert.deepEqual(
issuesWith({
nextEntry: { kind: 'button', buttonIndex: 7, hold: { kind: 'button', buttonIndex: 7 } },
}),
{ nextEntry: 'Hold and press are the same button' },
);
});
test('the secondary subtitle hold warns on a hold button, since it cannot wait for release', () => {
assert.deepEqual(
issuesWith({
playCurrentAudio: { kind: 'none' },
nextAudio: L1_DPAD_RIGHT,
holdSecondarySub: { kind: 'button', buttonIndex: 4 },
}),
{ holdSecondarySub: 'Also the hold button of Next Audio' },
);
});
test('a D-pad reported as a hat axis also counts as the D-pad', () => {
assert.deepEqual(
issuesWith({ previousEntry: { kind: 'axis', axisIndex: 7, direction: 'negative' } }),
@@ -1,3 +1,4 @@
import { controllerReviewInputSourceKey } from '../../shared/controller-review';
import { DPAD_AXIS_INDEX, DPAD_BUTTON_INDEX } from '../handlers/gamepad-controller.js';
import type {
ControllerDpadFallback,
@@ -31,10 +32,14 @@ const buttonKey = (buttonIndex: number) => `button:${buttonIndex}`;
const axisKey = (axisIndex: number, direction: 'negative' | 'positive') =>
`axis:${axisIndex}:${direction}`;
/**
* A combo counts as one input, apart from its hold and press alone: a plain action on the hold
* button waits for release, and a combo keeps its press away from plain actions and stick rows.
*/
function discreteInputs(binding: ResolvedControllerDiscreteBinding): string[] {
if (binding.kind === 'none') return [];
if ('buttonIndex' in binding) return [buttonKey(binding.buttonIndex)];
return [axisKey(binding.axisIndex, binding.direction)];
const press = controllerReviewInputSourceKey(binding);
return [binding.hold ? `${controllerReviewInputSourceKey(binding.hold)}+${press}` : press];
}
function stickInputs(binding: ResolvedControllerAxisBinding): string[] {
@@ -74,6 +79,8 @@ export function findControllerBindingIssues(
definitions: readonly ControllerBindingIssueDefinition[],
bindings: ControllerBindings,
): Partial<Record<string, string>> {
const issues: Partial<Record<string, string>> = {};
const holdActions = new Map<string, string>();
const rows: { key: string; label: string; inputs: string[] }[] = [];
for (const definition of definitions) {
if (INACTIVE_BINDINGS.has(definition.id)) continue;
@@ -92,15 +99,30 @@ export function findControllerBindingIssues(
inputs: dpadInputs(axisBinding.kind === 'none' ? 'none' : axisBinding.dpadFallback),
});
} else {
const discreteBinding = binding as ResolvedControllerDiscreteBinding;
rows.push({
key: definition.id,
label: definition.label,
inputs: discreteInputs(binding as ResolvedControllerDiscreteBinding),
inputs: discreteInputs(discreteBinding),
});
if (discreteBinding.kind !== 'none' && discreteBinding.hold) {
const holdKey = controllerReviewInputSourceKey(discreteBinding.hold);
if (holdKey === controllerReviewInputSourceKey(discreteBinding)) {
issues[definition.id] = 'Hold and press are the same button';
} else if (!holdActions.has(holdKey)) {
holdActions.set(holdKey, definition.label);
}
}
}
}
const issues: Partial<Record<string, string>> = {};
// Everything else on a hold button can wait for release. This one shows while held.
const secondaryHold = bindings.holdSecondarySub;
if (secondaryHold.kind !== 'none' && !secondaryHold.hold) {
const comboLabel = holdActions.get(controllerReviewInputSourceKey(secondaryHold));
if (comboLabel) issues.holdSecondarySub = `Also the hold button of ${comboLabel}`;
}
for (const row of rows) {
const inputs = new Set(row.inputs);
const clashes = rows
@@ -216,7 +216,7 @@ test('controller config form expands the learning row with a listening edit pane
assert.equal(panel.classList.contains('controller-config-edit-panel'), true);
const hint = findOne(panel, 'controller-config-edit-hint');
assert.equal(hint.textContent, 'Press a button, trigger, or move a stick…');
assert.equal(hint.textContent, 'Press a button, or hold one and press another for a combo…');
assert.equal(hint.classList.contains('learning'), true);
assert.equal(findOne(panel, 'btn-learn').textContent, 'Listening…');
});
+49 -28
View File
@@ -28,6 +28,7 @@ export type ControllerScreenLearning = {
};
import type {
ControllerDpadFallback,
ControllerRawInput,
ResolvedControllerAxisBinding,
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
@@ -296,22 +297,25 @@ export function getFriendlyAxisName(axisIndex: number, mapping?: ControllerMappi
return names[axisIndex] ?? `Axis ${axisIndex}`;
}
function formatInputSummary(input: ControllerRawInput): string {
return input.kind === 'button'
? `Button ${input.buttonIndex}`
: `Axis ${input.axisIndex} ${input.direction === 'positive' ? '+' : '-'}`;
}
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 ('dpadFallback' in binding) {
return binding.dpadFallback === 'none'
? `Axis ${binding.axisIndex}`
: `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
}
if ('buttonIndex' in binding) {
return `Button ${binding.buttonIndex}`;
}
if (binding.dpadFallback === 'none') {
return `Axis ${binding.axisIndex}`;
}
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
const press = formatInputSummary(binding);
return binding.hold ? `${formatInputSummary(binding.hold)} + ${press}` : press;
}
function formatFriendlyStickLabel(
@@ -322,20 +326,36 @@ function formatFriendlyStickLabel(
return getFriendlyAxisName(binding.axisIndex, mapping);
}
export function formatFriendlyInputLabel(
input: ControllerRawInput,
mapping: ControllerMapping,
faceButtonLabels: FaceButtonLabels,
): string {
if (input.kind === 'button') {
return getFriendlyButtonName(input.buttonIndex, mapping, faceButtonLabels);
}
const name = getFriendlyAxisName(input.axisIndex, mapping);
return `${name} ${input.direction === 'positive' ? '+' : '\u2212'}`;
}
function formatFriendlyBindingLabel(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
mapping: ControllerMapping,
faceButtonLabels: FaceButtonLabels,
): string {
if (binding.kind === 'none') return 'None';
if ('direction' in binding) {
const name = getFriendlyAxisName(binding.axisIndex, mapping);
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
}
if ('buttonIndex' in binding) {
return getFriendlyButtonName(binding.buttonIndex, mapping, faceButtonLabels);
}
return getFriendlyAxisName(binding.axisIndex, mapping);
if ('dpadFallback' in binding) return getFriendlyAxisName(binding.axisIndex, mapping);
const press = formatFriendlyInputLabel(binding, mapping, faceButtonLabels);
return binding.hold
? `${formatFriendlyInputLabel(binding.hold, mapping, faceButtonLabels)} + ${press}`
: press;
}
/** What Alt+C asks for while learning a binding that can be a combo. */
function formatComboLearnHint(holdLabel: string | null): string {
return holdLabel
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
: 'Press a button, or hold one and press another for a combo\u2026';
}
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
@@ -345,6 +365,8 @@ export function createControllerConfigForm(options: {
container: HTMLElement;
getBindings: () => ResolvedControllerConfig['bindings'];
getLearningActionId: () => ControllerBindingActionId | null;
/** The hold button pressed so far while learning a discrete action, if any. */
getLearningHoldLabel?: () => string | null;
getDpadLearningActionId: () => ControllerBindingActionId | null;
onLearn: (actionId: ControllerBindingActionId, bindingType: 'discrete' | 'axis') => void;
onClear: (actionId: ControllerBindingActionId) => void;
@@ -539,11 +561,7 @@ export function createControllerConfigForm(options: {
options.container.appendChild(row);
if (isExpanded) {
const hint = !isLearning
? `Currently: ${current}`
: holdLabel
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
: 'Press a button, or hold one and press another for a combo\u2026';
const hint = isLearning ? formatComboLearnHint(holdLabel) : `Currently: ${current}`;
const panel = createEditPanel(rowKey, hint, isLearning, {
onLearn: learn,
onClear: (e) => {
@@ -565,14 +583,17 @@ export function createControllerConfigForm(options: {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const holdLabel = isLearning ? (options.getLearningHoldLabel?.() ?? null) : null;
const row = createRow(
definition.label,
formatFriendlyBindingLabel(
binding,
options.getControllerMapping?.(),
options.getFaceButtonLabels?.(),
),
holdLabel
? `${holdLabel} + \u2026`
: formatFriendlyBindingLabel(
binding,
options.getControllerMapping?.(),
options.getFaceButtonLabels?.(),
),
binding.kind === 'none',
isExpanded,
`Learn ${definition.label}`,
@@ -596,7 +617,7 @@ export function createControllerConfigForm(options: {
if (isExpanded) {
const hint = isLearning
? 'Press a button, trigger, or move a stick\u2026'
? formatComboLearnHint(holdLabel)
: `Currently: ${formatControllerBindingSummary(binding)}`;
const panel = createEditPanel(rowKey, hint, isLearning, {
onLearn: (e) => {
@@ -294,6 +294,9 @@ test('controller select modal learn mode captures fresh button input and persist
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
// A lone button is only final once released: it could still become a combo's hold.
state.controllerRawButtons[11] = { value: 0, pressed: false, touched: false };
modal.updateDevices();
await Promise.resolve();
@@ -423,6 +426,9 @@ test('controller select modal binding badge starts learn mode and persists bindi
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
// A lone button is only final once released: it could still become a combo's hold.
state.controllerRawButtons[11] = { value: 0, pressed: false, touched: false };
modal.updateDevices();
await Promise.resolve();
@@ -543,6 +549,9 @@ test('controller select modal edit control starts learn mode and persists bindin
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
// A lone button is only final once released: it could still become a combo's hold.
state.controllerRawButtons[11] = { value: 0, pressed: false, touched: false };
modal.updateDevices();
await Promise.resolve();
@@ -736,6 +745,68 @@ test('controller select modal learns a review combo and saves it to the controll
}
});
test('controller select modal learns a combo for a main action', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const firstBadge = () => dom.controllerConfigList.children[1].children[1].children[0];
firstBadge().dispatch('click');
const buttons = (pressed: number[]) =>
Array.from({ length: 17 }, (_, index) => ({
value: pressed.includes(index) ? 1 : 0,
pressed: pressed.includes(index),
touched: pressed.includes(index),
}));
// Hold L1, then press D-pad right.
state.controllerRawButtons = buttons([4]);
modal.updateDevices();
assert.equal(firstBadge().textContent, 'LB / L1 + \u2026');
state.controllerRawButtons = buttons([4, 15]);
modal.updateDevices();
// Let the save finish so the row re-renders with the saved combo.
await new Promise((resolve) => setTimeout(resolve, 0));
const binding = {
kind: 'button',
buttonIndex: 15,
hold: { kind: 'button', buttonIndex: 4 },
};
assert.deepEqual(saved.at(-1), {
profiles: { 'pad-1': { bindings: { toggleLookup: binding } } },
});
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, binding);
assert.equal(firstBadge().textContent, 'LB / L1 + D-pad Right');
} finally {
domHandle.restore();
}
});
function findAllByClass(root: any, className: string, found: any[] = []): any[] {
for (const child of root.children ?? []) {
if (
+84 -38
View File
@@ -1,7 +1,10 @@
import type { ModalStateReader, RendererContext } from '../context';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
import {
createControllerComboCapture,
type ControllerCapturedCombo,
} from '../handlers/controller-combo-capture.js';
import {
CONTROLLER_FACE_BUTTON_STYLES,
formatControllerReviewInput,
@@ -18,11 +21,14 @@ import {
} from '../../shared/controller-screens';
import {
createControllerConfigForm,
formatFriendlyInputLabel,
getControllerBindingDefinition,
getDefaultControllerBinding,
getDefaultDpadFallback,
} from './controller-config-form.js';
import type { ControllerDpadFallback, ControllerRawInput } from '../../types';
const PRESS_TO_EDIT_STATUS = 'Press any button on your controller to edit its bindings.';
/** `8BitDo Ultimate Wireless Controller (STANDARD GAMEPAD Vendor: …)` → the name part. */
@@ -75,12 +81,15 @@ export function createControllerSelectModal(
let learningActionId: ControllerBindingKey | null = null;
let dpadLearningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
let screenLearning: {
screen: ControllerScreenDefinition;
actionId: string;
type ComboLearning = {
capture: ReturnType<typeof createControllerComboCapture>;
holdLabel: string | null;
} | null = null;
};
/** Set with `learningActionId` while learning a discrete row. */
let discreteLearning: ComboLearning | null = null;
let screenLearning:
| (ComboLearning & { screen: ControllerScreenDefinition; actionId: string })
| null = null;
/** The controller the overlay reports in use, fetched when the window opens. */
let overlayActiveControllerId: string | null = null;
@@ -161,6 +170,36 @@ export function createControllerSelectModal(
screenLearning = null;
}
function startComboLearning(): ComboLearning {
const config = ctx.state.controllerConfig;
const capture = createControllerComboCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
capture.arm({ axes: ctx.state.controllerRawAxes, buttons: ctx.state.controllerRawButtons });
return { capture, holdLabel: null };
}
/** Advances a combo capture, showing the hold button so far; returns the finished combo. */
function pollComboLearning(
learning: ComboLearning,
formatHold: (input: ControllerRawInput) => string,
): ControllerCapturedCombo | null {
const state = learning.capture.poll({
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
});
if (state.status === 'done') return state.binding;
if (state.status === 'holding') {
const holdLabel = formatHold(state.hold);
if (holdLabel !== learning.holdLabel) {
learning.holdLabel = holdLabel;
controllerConfigForm.render();
}
}
return null;
}
function actionLabel(screen: ControllerScreenDefinition, actionId: string): string {
return getControllerScreenAction(screen, actionId).label;
}
@@ -203,6 +242,7 @@ export function createControllerSelectModal(
}));
},
getScreenInputContext,
getLearningHoldLabel: () => discreteLearning?.holdLabel ?? null,
getScreenLearning: () =>
screenLearning
? {
@@ -216,14 +256,9 @@ export function createControllerSelectModal(
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
const config = ctx.state.controllerConfig;
const capture = createControllerComboCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
capture.arm({ axes: ctx.state.controllerRawAxes, buttons: ctx.state.controllerRawButtons });
discreteLearning = null;
const screen = getControllerScreen(screenId);
screenLearning = { screen, actionId, capture, holdLabel: null };
screenLearning = { ...startComboLearning(), screen, actionId };
controllerConfigForm.render();
setStatus(
`Press a button for ${actionLabel(screen, actionId)}, or hold one and press another for a combo.`,
@@ -264,6 +299,17 @@ export function createControllerSelectModal(
if (!definition) return;
dpadLearningActionId = null;
cancelScreenLearning();
learningActionId = actionId;
if (bindingType === 'discrete') {
bindingCapture = null;
discreteLearning = startComboLearning();
controllerConfigForm.render();
setStatus(
`Press a button for ${definition.label}, or hold one and press another for a combo.`,
);
return;
}
discreteLearning = null;
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
@@ -275,22 +321,12 @@ export function createControllerSelectModal(
? currentBinding.dpadFallback
: 'none';
bindingCapture.arm(
bindingType === 'axis'
? {
actionId,
bindingType: 'axis',
dpadFallback: currentDpadFallback,
}
: {
actionId,
bindingType: 'discrete',
},
{ actionId, bindingType: 'axis', dpadFallback: currentDpadFallback },
{
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
},
);
learningActionId = actionId;
controllerConfigForm.render();
setStatus(`Waiting for input for ${definition.label}.`);
},
@@ -307,6 +343,7 @@ export function createControllerSelectModal(
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
learningActionId = null;
discreteLearning = null;
cancelScreenLearning();
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
@@ -564,6 +601,7 @@ export function createControllerSelectModal(
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
discreteLearning = null;
controllerConfigForm.render();
setStatus(`${definition?.label ?? actionId} updated.`);
} catch (error) {
@@ -592,7 +630,7 @@ export function createControllerSelectModal(
async function saveDpadFallback(
actionId: ControllerBindingKey,
dpadFallback: import('../../types').ControllerDpadFallback,
dpadFallback: ControllerDpadFallback,
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
@@ -665,25 +703,31 @@ export function createControllerSelectModal(
}
}
if (discreteLearning && learningActionId) {
const actionId = learningActionId;
const combo = pollComboLearning(discreteLearning, (input) =>
formatFriendlyInputLabel(
input,
getSelectedController()?.mapping,
getSelectedControllerConfig()?.faceButtonLabels,
),
);
if (combo) {
discreteLearning = null;
void saveBinding(actionId, combo.hold ? { ...combo.press, hold: combo.hold } : combo.press);
}
}
if (screenLearning) {
const learning = screenLearning;
const state = learning.capture.poll({
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
});
if (state.status === 'holding') {
const holdLabel = formatControllerReviewInput(state.hold, getScreenInputContext(), {
dpadPrefix: true,
});
if (holdLabel !== learning.holdLabel) {
learning.holdLabel = holdLabel;
controllerConfigForm.render();
}
} else if (state.status === 'done') {
const combo = pollComboLearning(learning, (input) =>
formatControllerReviewInput(input, getScreenInputContext(), { dpadPrefix: true }),
);
if (combo) {
screenLearning = null;
void saveScreenBindings(
learning.screen,
{ [learning.actionId]: state.binding },
{ [learning.actionId]: combo },
`${actionLabel(learning.screen, learning.actionId)} updated.`,
);
}
@@ -741,6 +785,7 @@ export function createControllerSelectModal(
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
discreteLearning = null;
cancelScreenLearning();
ctx.state.controllerSelectModalOpen = false;
ctx.state.controllerSelectGamepadId = null;
@@ -760,6 +805,7 @@ export function createControllerSelectModal(
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
discreteLearning = null;
cancelScreenLearning();
controllerConfigForm.render();
setStatus('Controller learn mode cancelled.');
+22
View File
@@ -8,6 +8,8 @@ import type {
ControllerReviewInput,
ResolvedControllerReviewBinding,
ResolvedControllerReviewBindings,
ControllerRawInput,
ResolvedControllerDiscreteBinding,
} from '../types/runtime';
/**
@@ -216,6 +218,26 @@ export function parseControllerReviewInput(value: unknown): ControllerReviewInpu
}
}
function parseControllerRawInput(value: unknown): ControllerRawInput | null {
const input = parseControllerReviewInput(value);
return input?.kind === 'button' || input?.kind === 'axis' ? input : null;
}
/**
* One main-list binding, as read from the config file and from Alt+C saves over IPC. An invalid
* `hold` rejects the whole binding, so a broken combo never becomes a plain press.
*/
export function parseControllerDiscreteBinding(
value: unknown,
): ResolvedControllerDiscreteBinding | null {
if (!isRecord(value)) return null;
if (value.kind === 'none') return { kind: 'none' };
const press = parseControllerRawInput(value);
if (!press || value.hold === undefined || value.hold === null) return press;
const hold = parseControllerRawInput(value.hold);
return hold ? { ...press, hold } : null;
}
/** Accepts `{ "kind": "none" }` or `{ "press": …, "hold"?: … }` (`kind: "combo"` optional). */
export function parseControllerReviewBinding(
value: unknown,
+2 -19
View File
@@ -17,7 +17,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
import { isControllerFaceButtonLabels } from '../controller-review';
import { isControllerFaceButtonLabels, parseControllerDiscreteBinding } from '../controller-review';
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -142,23 +142,6 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
};
}
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' };
@@ -230,7 +213,7 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
] as const;
for (const key of discreteKeys) {
if (value[key] === undefined) continue;
const parsed = parseDiscreteBinding(value[key]);
const parsed = parseControllerDiscreteBinding(value[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
+7
View File
@@ -191,12 +191,16 @@ export interface ControllerNoneBinding {
export interface ControllerButtonInputBinding {
kind: 'button';
buttonIndex: number;
/** Makes this a combo: it fires when this input is pressed while `hold` is down. */
hold?: ControllerRawInput;
}
export interface ControllerAxisDirectionInputBinding {
kind: 'axis';
axisIndex: number;
direction: ControllerAxisDirection;
/** Makes this a combo: it fires when this input is pressed while `hold` is down. */
hold?: ControllerRawInput;
}
export interface ControllerAxisInputBinding {
@@ -343,6 +347,9 @@ export type ControllerReviewInput =
| { kind: 'face'; letter: ControllerFaceLetter }
| { kind: 'dpad'; direction: ControllerDpadDirection };
/** An input as Learn records it: a raw button index or a raw axis direction. */
export type ControllerRawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
/** Press `press`, optionally while holding `hold`. */
export type ControllerReviewBindingConfig =
| { kind: 'none' }