diff --git a/changes/controller-dpad-learn.md b/changes/controller-dpad-learn.md new file mode 100644 index 00000000..3e2a3be0 --- /dev/null +++ b/changes/controller-dpad-learn.md @@ -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. diff --git a/changes/controller-main-combos.md b/changes/controller-main-combos.md new file mode 100644 index 00000000..57262740 --- /dev/null +++ b/changes/controller-main-combos.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 473ad2f9..198219c2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index a8119e65..0edafe52 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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 diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 473ad2f9..198219c2 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. diff --git a/docs-site/usage.md b/docs-site/usage.md index fb5cbb4f..53c1febf 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -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 | | --------------------- | -------------------------------------- | diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index a65237e2..79cd0737 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -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) => + overlay.evaluate>( + `(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', diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index e4f45e3b..4b0fd2bd 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -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)); } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 721c4dcd..faf81bc9 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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( diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index e2d8c8a6..faa4307e 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -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) => [ { diff --git a/src/config/resolve/controller.ts b/src/config/resolve/controller.ts index e4f8cba9..12cb1469 100644 --- a/src/config/resolve/controller.ts +++ b/src/config/resolve/controller.ts @@ -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; type ControllerButtonIndicesTarget = Required; -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'.", ); } } diff --git a/src/main.ts b/src/main.ts index a8d05e20..f5cace9a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/main/controller-config-update.test.ts b/src/main/controller-config-update.test.ts index 46cf78ed..5bd42e71 100644 --- a/src/main/controller-config-update.test.ts +++ b/src/main/controller-config-update.test.ts @@ -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( diff --git a/src/main/controller-config-update.ts b/src/main/controller-config-update.ts index a895f7b3..4e9b0574 100644 --- a/src/main/controller-config-update.ts +++ b/src/main/controller-config-update.ts @@ -1,3 +1,4 @@ +import type { ConfigService } from '../config/service'; import type { ControllerConfigUpdate, RawConfig } from '../types'; type RawControllerConfig = NonNullable; @@ -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, + update: ControllerConfigUpdate, +): void { + const rawConfig = configService.getRawConfig(); + configService.saveRawConfig({ + ...rawConfig, + controller: applyControllerConfigUpdate(rawConfig.controller, update), + }); +} + export function applyControllerConfigUpdate( currentController: RawConfig['controller'] | undefined, update: ControllerConfigUpdate, diff --git a/src/renderer/handlers/controller-binding-capture.test.ts b/src/renderer/handlers/controller-binding-capture.test.ts index 05802a90..121276c8 100644 --- a/src/renderer/handlers/controller-binding-capture.test.ts +++ b/src/renderer/handlers/controller-binding-capture.test.ts @@ -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); diff --git a/src/renderer/handlers/controller-binding-capture.ts b/src/renderer/handlers/controller-binding-capture.ts index 9e694369..5cb13d12 100644 --- a/src/renderer/handlers/controller-binding-capture.ts +++ b/src/renderer/handlers/controller-binding-capture.ts @@ -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; } diff --git a/src/renderer/handlers/controller-combo-capture.test.ts b/src/renderer/handlers/controller-combo-capture.test.ts index e3c768b3..0ac4aede 100644 --- a/src/renderer/handlers/controller-combo-capture.test.ts +++ b/src/renderer/handlers/controller-combo-capture.test.ts @@ -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, + }, + }); +}); diff --git a/src/renderer/handlers/controller-combo-capture.ts b/src/renderer/handlers/controller-combo-capture.ts index 69ee5f10..c1832042 100644 --- a/src/renderer/handlers/controller-combo-capture.ts +++ b/src/renderer/handlers/controller-combo-capture.ts @@ -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; +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(); const restingAxes = new Set(); - 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 }; } diff --git a/src/renderer/handlers/controller-combo-tracker.ts b/src/renderer/handlers/controller-combo-tracker.ts new file mode 100644 index 00000000..434eec5c --- /dev/null +++ b/src/renderer/handlers/controller-combo-tracker.ts @@ -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; + +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 = new Set(); + let holdKeys: ReadonlySet = new Set(); + let heldHoldKeys: string[] = []; + let usedHolds: ReadonlySet = new Set(); + /** Holds used during their press, kept for the poll that sees them released. */ + let cancelledTaps: ReadonlySet = new Set(); + const claimedInputs = new Map(); + + function update( + bindings: readonly ResolvedControllerDiscreteBinding[], + isPressed: (input: ControllerRawInput) => boolean, + ): void { + const bound = bindings.filter(isBound); + const pressed = new Set(); + 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, + }; + }, + }; +} diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index f29348ee..e2609b94 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -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, + deps: Partial = {}, + extraConfig: Partial = {}, +) { + 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, []); +}); diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index 95d63eec..eb641f94 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -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(); + const comboTracker = createControllerComboTracker(); + /** Plain actions on a hold button, pressed and waiting to see whether they were a tap. */ + const pendingTaps = new Set(); + const handledThisPoll = new Set(); 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, diff --git a/src/renderer/modals/controller-binding-issues.test.ts b/src/renderer/modals/controller-binding-issues.test.ts index 9e0589c8..93bf477c 100644 --- a/src/renderer/modals/controller-binding-issues.test.ts +++ b/src/renderer/modals/controller-binding-issues.test.ts @@ -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' } }), diff --git a/src/renderer/modals/controller-binding-issues.ts b/src/renderer/modals/controller-binding-issues.ts index 83bdbcfc..d2495957 100644 --- a/src/renderer/modals/controller-binding-issues.ts +++ b/src/renderer/modals/controller-binding-issues.ts @@ -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> { + const issues: Partial> = {}; + const holdActions = new Map(); 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> = {}; + // 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 diff --git a/src/renderer/modals/controller-config-form.test.ts b/src/renderer/modals/controller-config-form.test.ts index 23bd9a0d..a5096be6 100644 --- a/src/renderer/modals/controller-config-form.test.ts +++ b/src/renderer/modals/controller-config-form.test.ts @@ -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…'); }); diff --git a/src/renderer/modals/controller-config-form.ts b/src/renderer/modals/controller-config-form.ts index 5e7a3a10..0d22ea47 100644 --- a/src/renderer/modals/controller-config-form.ts +++ b/src/renderer/modals/controller-config-form.ts @@ -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) => { diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index fbc807f6..18f0435e 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -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 ( diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index d0627058..99c5d0a2 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -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 | null = null; - let screenLearning: { - screen: ControllerScreenDefinition; - actionId: string; + type ComboLearning = { capture: ReturnType; 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 { 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.'); diff --git a/src/shared/controller-review.ts b/src/shared/controller-review.ts index 58af6c0b..8a90406c 100644 --- a/src/shared/controller-review.ts +++ b/src/shared/controller-review.ts @@ -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, diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index acc8521a..45e24a6a 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -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[typeof key]; } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index fc98be57..09c6fede 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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; + /** Press `press`, optionally while holding `hold`. */ export type ControllerReviewBindingConfig = | { kind: 'none' }